fix: restore rectification agent message actions

This commit is contained in:
Jesse_Chen
2026-07-30 00:41:48 +08:00
parent a5f409a500
commit 0a5b8cd239
15 changed files with 513 additions and 4 deletions
@@ -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);
});
}
});