fix: restore rectification agent message actions
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { rectificationV4ChatMessages } from "../src/components/rectification-v4-panel.tsx";
|
||||
import {
|
||||
canRegenerateRectificationMessage,
|
||||
rectificationV4ChatMessages,
|
||||
toggleRectificationFeedback,
|
||||
} from "../src/components/rectification-v4-panel.tsx";
|
||||
import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts";
|
||||
|
||||
const id = "00000000-0000-4000-8000-000000000901";
|
||||
@@ -87,6 +91,11 @@ test("v4 rectification reuses the ordinary session message list, composer, and m
|
||||
assert.match(component, /className="composer"/);
|
||||
assert.match(component, /<Textarea/);
|
||||
assert.match(component, /<ModelSelector/);
|
||||
assert.match(component, /aria-label="赞"/);
|
||||
assert.match(component, /aria-label="踩"/);
|
||||
assert.match(component, /aria-label="重新生成回答"/);
|
||||
assert.match(component, /caseValue\?\.deploymentMode === "v5_agent"/);
|
||||
assert.match(component, /controller\.regenerate\(\)/);
|
||||
assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/);
|
||||
assert.match(wrapper, /<RectificationV4Panel \{\.\.\.props\} \/>/);
|
||||
assert.match(page, /\{!rectificationSurfaceOpen && \(\s*<div className=\{`conversation/);
|
||||
@@ -96,6 +105,42 @@ test("v4 rectification reuses the ordinary session message list, composer, and m
|
||||
assert.doesNotMatch(css, /\.rectification-chat/);
|
||||
});
|
||||
|
||||
test("V5 Agent feedback is mutually exclusive and regenerate is limited to the current settled assistant question", () => {
|
||||
assert.equal(toggleRectificationFeedback(undefined, "up"), "up");
|
||||
assert.equal(toggleRectificationFeedback("up", "up"), undefined);
|
||||
assert.equal(toggleRectificationFeedback("up", "down"), "down");
|
||||
|
||||
const message = rectificationV4ChatMessages(response(), false).at(-1)!;
|
||||
assert.equal(canRegenerateRectificationMessage({
|
||||
message,
|
||||
currentMessageKey: `rectification-current-${questionId}`,
|
||||
deploymentMode: "v5_agent",
|
||||
busy: false,
|
||||
canAnswer: true,
|
||||
}), true);
|
||||
assert.equal(canRegenerateRectificationMessage({
|
||||
message,
|
||||
currentMessageKey: message.renderKey,
|
||||
deploymentMode: "v5_shadow",
|
||||
busy: false,
|
||||
canAnswer: true,
|
||||
}), false);
|
||||
assert.equal(canRegenerateRectificationMessage({
|
||||
message,
|
||||
currentMessageKey: message.renderKey,
|
||||
deploymentMode: "v5_agent",
|
||||
busy: true,
|
||||
canAnswer: true,
|
||||
}), false);
|
||||
assert.equal(canRegenerateRectificationMessage({
|
||||
message: { ...message, role: "user" },
|
||||
currentMessageKey: message.renderKey,
|
||||
deploymentMode: "v5_agent",
|
||||
busy: false,
|
||||
canAnswer: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("turn history and the context-aware next question render as one chat timeline", () => {
|
||||
const messages = rectificationV4ChatMessages(response(), false);
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ test("the v4 rectification chat renders persisted turns as alternating message h
|
||||
assert.match(source, /for \(const turn of data\.turns\)/);
|
||||
assert.match(source, /role: "assistant"[\s\S]*?text: turn\.question/);
|
||||
assert.match(source, /role: "user"[\s\S]*?text: turn\.answer/);
|
||||
assert.match(source, /messages\.map\(\(message\) => <ChatMessageRow/);
|
||||
assert.match(source, /messages\.map\(\(message\) => \{/);
|
||||
assert.match(source, /<ChatMessageRow message=/);
|
||||
assert.doesNotMatch(source, /rectification-progress-details|evidenceRecap\.map/);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
const sql = readFileSync(new URL("../supabase/migrations/20260726020000_birth_time_rectification_v4.sql", import.meta.url), "utf8");
|
||||
const conversationalSql = readFileSync(new URL("../supabase/migrations/20260727010000_rectification_v4_conversational_turns.sql", import.meta.url), "utf8");
|
||||
const regenerationSql = readFileSync(new URL("../supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql", import.meta.url), "utf8");
|
||||
|
||||
test("v4 migration creates canonical append-only storage and leased jobs", () => {
|
||||
for (const table of [
|
||||
@@ -44,3 +45,16 @@ test("v4 handoff SQL enforces range acceptance, leases, idempotent settlement, a
|
||||
assert.match(sql, /birth_time_rectification_v4_handoff_settlements/i);
|
||||
assert.doesNotMatch(sql, /update\s+public\.profiles[\s\S]*active_birth_time/i);
|
||||
});
|
||||
|
||||
|
||||
test("current-question regeneration is service-role-only, atomic, idempotent, and preserves the semantic target", () => {
|
||||
assert.match(regenerationSql, /create or replace function public\.replace_birth_time_rectification_v4_current_question/i);
|
||||
assert.match(regenerationSql, /where value\.id = p_case_id and value\.user_id = p_user_id[\s\S]*for update/i);
|
||||
assert.match(regenerationSql, /where action\.user_id = p_user_id and action\.action_id = p_action_id[\s\S]*return v_case_id/i);
|
||||
assert.match(regenerationSql, /v_case\.version <> p_expected_version[\s\S]*stale_rectification_v4_case/i);
|
||||
assert.match(regenerationSql, /v_case\.deployment_mode <> 'v5_agent'/i);
|
||||
assert.match(regenerationSql, /'domain', v_case\.current_question->'domain'[\s\S]*'targetEventId', v_case\.current_question->'targetEventId'/i);
|
||||
assert.match(regenerationSql, /grant execute on function public\.replace_birth_time_rectification_v4_current_question\([\s\S]*to service_role/i);
|
||||
assert.doesNotMatch(regenerationSql, /insert into public\.birth_time_rectification_v4_(?:turns|events|jobs|candidate_snapshots)/i);
|
||||
assert.doesNotMatch(regenerationSql, /update\s+public\.profiles/i);
|
||||
});
|
||||
|
||||
@@ -148,3 +148,72 @@ test("legacy cases are not hard-switched to V5 even when flags change later", as
|
||||
assert.equal(run.fallbackReason, "deployment_mode_legacy");
|
||||
});
|
||||
});
|
||||
|
||||
test("V5 Agent regenerate rewrites only the current semantic question and replays the same action once", async () => withMode("v5_agent", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const queued = await service.answer({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: created.case.version,
|
||||
answer: "2020年4月去石油化工研究院实习做研究员",
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
const worker = createRectificationV4Worker({
|
||||
store,
|
||||
now: fixedNow,
|
||||
engine: { async score() { throw new Error("engine must not run before enough events"); } },
|
||||
});
|
||||
assert.equal(await worker.runOnce(), true);
|
||||
|
||||
const before = await service.loadCase(userId, created.case.id);
|
||||
assert.ok(before?.case.currentQuestion);
|
||||
const actionId = randomUUID();
|
||||
const regenerated = await service.regenerateQuestion({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId,
|
||||
expectedCaseVersion: before.case.version,
|
||||
});
|
||||
assert.ok(regenerated?.case.currentQuestion);
|
||||
assert.equal(regenerated.case.version, before.case.version + 1);
|
||||
assert.notEqual(regenerated.case.currentQuestion.id, before.case.currentQuestion.id);
|
||||
assert.equal(regenerated.case.currentQuestion.domain, before.case.currentQuestion.domain);
|
||||
assert.equal(regenerated.case.currentQuestion.targetEventId, before.case.currentQuestion.targetEventId);
|
||||
assert.equal(regenerated.case.evidenceSetHash, before.case.evidenceSetHash);
|
||||
assert.deepEqual(regenerated.events, before.events);
|
||||
assert.deepEqual(regenerated.turns, before.turns);
|
||||
assert.deepEqual(regenerated.case.latestSnapshot, before.case.latestSnapshot);
|
||||
assert.equal(store.jobs.size, 1);
|
||||
|
||||
const replayed = await service.regenerateQuestion({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId,
|
||||
expectedCaseVersion: before.case.version,
|
||||
});
|
||||
assert.equal(replayed?.case.version, regenerated.case.version);
|
||||
assert.equal(replayed?.case.currentQuestion?.id, regenerated.case.currentQuestion.id);
|
||||
assert.equal(store.jobs.size, 1);
|
||||
assert.equal(regenerated.case.latestSnapshot?.canConfirmExactMinute ?? false, false);
|
||||
}));
|
||||
|
||||
test("legacy and shadow cases cannot call the V5 Agent question renderer", async () => {
|
||||
for (const mode of ["v4_legacy", "v5_shadow"] as const) {
|
||||
await withMode(mode, async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
assert.equal(await service.regenerateQuestion({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: created.case.version,
|
||||
}), null);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user