fix(rectification): restore message actions and retry receipts
Independent Staging Quality Gate / validate (push) Failing after 30m22s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-14 00:10:22 +08:00
parent e8952dcc75
commit 2e17ccad64
17 changed files with 1225 additions and 60 deletions
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createRectificationActivityReceiptState,
reduceRectificationActivityReceipt,
receiptFromRectificationActivityState,
} from "../src/lib/rectification-activity-receipt";
test("a successful retry clears the same tool's earlier failure", () => {
let state = createRectificationActivityReceiptState();
for (const event of [
{ tool: "rectification-read-case", status: "completed" },
{ tool: "rectification-propose-evidence", status: "failed" },
{ tool: "rectification-propose-evidence", status: "started" },
{ tool: "rectification-propose-evidence", status: "completed" },
{ tool: "rectification-confirm-evidence", status: "completed" },
] as const) {
state = reduceRectificationActivityReceipt(state, event);
}
assert.deepEqual(receiptFromRectificationActivityState(state), {
steps: [
"rectification-read-case",
"rectification-propose-evidence",
"rectification-confirm-evidence",
],
methods: [],
});
});
test("an unrecovered latest tool failure remains visible", () => {
let state = createRectificationActivityReceiptState();
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-read-case",
status: "completed",
});
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-propose-evidence",
status: "failed",
});
assert.deepEqual(receiptFromRectificationActivityState(state), {
steps: ["rectification-read-case"],
methods: [],
failedTool: "rectification-propose-evidence",
});
});
test("a later failure overrides an earlier completion for the same tool", () => {
let state = createRectificationActivityReceiptState();
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-read-case",
status: "completed",
});
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-read-case",
status: "failed",
});
assert.deepEqual(receiptFromRectificationActivityState(state), {
steps: ["rectification-read-case"],
methods: [],
failedTool: "rectification-read-case",
});
});
@@ -24,6 +24,13 @@ const completedActivityReceipt = readFileSync(
new URL("../src/components/completed-activity-receipt.tsx", import.meta.url),
"utf8",
);
const regenerateRoute = readFileSync(
new URL(
"../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts",
import.meta.url,
),
"utf8",
);
test("birth-time rectification entry mounts the V9 case-ref chat", () => {
assert.match(component, /return <RectificationAgenticChat \{\.\.\.props\} \/>/);
@@ -193,18 +200,21 @@ test("rectification activity separates live work from the receipt above the Agen
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
assert.match(chat, /activeActivity\?: PublicActivity/);
assert.match(chat, /completedReceipt\?: CompletedActivityReceiptView/);
assert.match(chat, /showActivity=\{message\.state === "thinking" \|\| Boolean\(message\.activeActivity\)\}/);
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking" \|\| Boolean\(displayedMessage\.activeActivity\)\}/);
assert.match(chat, /"rectification-read-case": "正在读取校正记录…"/);
assert.match(chat, /event\.type === "tool\.activity"/);
assert.match(chat, /event\.status === "started"/);
assert.match(chat, /event\.status === "completed"/);
assert.match(chat, /event\.status === "failed"/);
assert.match(chat, /event\.status !== "completed" && event\.status !== "failed"/);
const messageRender = chat.slice(
chat.indexOf('{messages.map((message) => ('),
chat.indexOf('{candidateResult?.selectionAllowed'),
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{candidateResult?.selectionAllowed"),
);
assert.ok(messageRender.indexOf("<CompletedActivityReceipt") < messageRender.indexOf("<ChatMessageRow"));
const receiptIndex = messageRender.indexOf("<CompletedActivityReceipt");
const replyIndex = messageRender.indexOf("<ChatMessageRow");
assert.ok(receiptIndex >= 0 && replyIndex >= 0);
assert.ok(receiptIndex < replyIndex);
assert.match(completedActivityReceipt, /<details className=/);
assert.doesNotMatch(completedActivityReceipt, /<details[^>]*\sopen/);
assert.match(completedActivityReceipt, /本轮完成 · \$\{stepLabels\.length\} 个步骤 · \$\{receipt\.methods\.length\} 项计算依据/);
@@ -223,6 +233,30 @@ test("rectification activity separates live work from the receipt above the Agen
assert.doesNotMatch(receiptRule, /border:/);
});
test("completed Agent replies restore feedback, copy and safe in-place regeneration actions", () => {
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(chat, new RegExp(`aria-label="${label}"`));
}
assert.match(chat, /toggleRectificationFeedback/);
assert.match(chat, /navigator\.clipboard\.writeText\(message\.text\)/);
assert.match(chat, /\/turns\/\$\{encodeURIComponent\(message\.turnId\)\}\/regenerate/);
assert.match(chat, /requestId: globalThis\.crypto\.randomUUID\(\)/);
const messageRender = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{candidateResult?.selectionAllowed"),
);
const receiptIndex = messageRender.indexOf("<CompletedActivityReceipt");
const replyIndex = messageRender.indexOf("<ChatMessageRow");
const actionsIndex = messageRender.indexOf('className="rectification-message-actions"');
assert.ok(receiptIndex >= 0 && replyIndex >= 0 && actionsIndex >= 0);
assert.ok(receiptIndex < replyIndex && replyIndex < actionsIndex);
assert.doesNotMatch(chat, /send\("message",\s*(?:oldText|previousText)/);
assert.doesNotMatch(regenerateRoute, /consultation-billing|reserveConsultation|billing\./);
assert.match(regenerateRoute, /regenerateV9AssistantTurn/);
});
test("candidate state renders from the snapshot API and never from sentinels", () => {
assert.match(chat, /当前可能的出生时间/);
assert.match(chat, /可以先采用一个作为当前排盘时间,也可以继续补充事件/);
+35 -1
View File
@@ -10,9 +10,13 @@ import {
RECTIFICATION_V9_SKILL_PATH,
RECTIFICATION_V9_SKILL_NAME,
getRectificationV9Agent,
getRectificationV9RegenerationAgent,
resolveRectificationStepBudget,
} from "../src/mastra/agentic-rectification.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
createRectificationV9ReadOnlyTools,
createRectificationV9Tools,
} from "../src/mastra/rectification-v9-tools.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
@@ -464,3 +468,33 @@ test("agent construction wires the pinned skill and the ten v9 tools", () => {
assert.equal(agent.id, "rectification-v9-test-model");
assert.ok(agent);
});
test("reply regeneration is a separate Jyotisha agent with only read-case access", () => {
const model = {
id: "test-model",
label: "Test",
description: "",
creditCost: 1,
isDefault: true,
mode: "compatible" as const,
model: { provider: "openai", name: "gpt-4o-mini", modelId: "gpt-4o-mini" } as never,
};
const accounting = fakeAccounting({});
const tools = createRectificationV9ReadOnlyTools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
assert.deepEqual(Object.keys(tools), ["rectification-read-case"]);
const agent = getRectificationV9RegenerationAgent(model, {
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
assert.equal(agent.id, "rectification-v9-regeneration-test-model");
assert.match(agentSource, /这不是新一轮校正/);
assert.match(agentSource, /只能使用 rectification-read-case/);
});
@@ -600,3 +600,63 @@ test("candidate reselection migration stays out of the identity migration tree",
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
});
// ---------------------------------------------------------------------------
// 20260813060000_rectification_turn_regeneration.sql
// ---------------------------------------------------------------------------
const turnRegenerationMigration = readFileSync(
new URL(
"../supabase/migrations/20260813060000_rectification_turn_regeneration.sql",
import.meta.url,
),
"utf8",
);
const turnRegenerationMigrationCopy = fileURLToPath(
new URL(
"../db/migrations/20260813060000_rectification_turn_regeneration.sql",
import.meta.url,
),
);
test("turn regeneration migration is forward-only, transactional and business-tree only", () => {
assert.ok(
"20260813060000_rectification_turn_regeneration.sql"
> "20260813050000_allow_rectification_candidate_reselection.sql",
);
assert.match(turnRegenerationMigration, /^begin;[\s\S]*^commit;$/m);
assert.equal(
existsSync(turnRegenerationMigrationCopy),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
});
test("turn regeneration is ownership-bound, latest-only and request-id idempotent", () => {
assert.match(turnRegenerationMigration, /where id = p_case_id and user_id = p_user_id/);
assert.match(turnRegenerationMigration, /if v_case\.session_id <> p_session_id/);
assert.match(turnRegenerationMigration, /where request_id = p_request_id/);
assert.match(turnRegenerationMigration, /agentic_rectification_regeneration_request_conflict/);
assert.match(turnRegenerationMigration, /pg_advisory_xact_lock/);
assert.match(turnRegenerationMigration, /v_case\.status in \('confirmed', 'closed', 'abandoned', 'superseded'\)/);
assert.match(turnRegenerationMigration, /where id = p_turn_id and case_id = p_case_id/);
assert.match(turnRegenerationMigration, /v_turn\.status <> 'completed'/);
assert.match(turnRegenerationMigration, /order by created_at desc, id desc[\s\S]*limit 1/);
assert.match(turnRegenerationMigration, /v_latest_turn_id is distinct from p_turn_id/);
});
test("turn regeneration updates only the existing Assistant text and timestamp", () => {
const update = turnRegenerationMigration.slice(
turnRegenerationMigration.indexOf("update public.agentic_rectification_turns"),
turnRegenerationMigration.indexOf("insert into public.agentic_rectification_turn_regenerations"),
);
const setClause = update.slice(update.indexOf("set "), update.indexOf("where "));
assert.match(update, /set assistant_message = v_message,[\s\S]*updated_at = pg_catalog\.now\(\)/);
assert.doesNotMatch(setClause, /user_message|status\s*=|case_id\s*=|session_id\s*=/);
assert.doesNotMatch(
turnRegenerationMigration,
/update public\.(?:agentic_rectification_evidence|agentic_rectification_results|agentic_rectification_cases|profiles)/,
);
assert.match(turnRegenerationMigration, /create or replace function public\.get_agentic_rectification_turn_regeneration/);
assert.match(turnRegenerationMigration, /create or replace function public\.regenerate_agentic_rectification_turn/);
});
@@ -0,0 +1,229 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
regenerateV9AssistantTurn,
type RectificationRegenerationAgent,
} from "../src/lib/rectification-agentic/v9/regenerate-turn.ts";
import { RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
SESSION_ID,
SOURCE_TURN_ID,
TURN_ID,
USER_ID,
dossierFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const REQUEST_ID = "88888888-8888-4888-8888-888888888888";
const OLD_REPLY = "旧的 Agent 正文";
const NEW_REPLY = "新的自然回复";
function completedTurns() {
return [
{
id: TURN_ID,
role: "user",
text: "2020 年 4 月开始实习",
status: "completed",
created_at: "2026-08-13T01:00:00.000Z",
},
{
id: TURN_ID,
role: "assistant",
text: OLD_REPLY,
status: "completed",
created_at: "2026-08-13T01:00:00.000Z",
},
];
}
function fakeAgent(overrides: Partial<RectificationRegenerationAgent> = {}) {
const calls = { getSkill: 0, generate: 0 };
const agent: RectificationRegenerationAgent = {
async getSkill() {
calls.getSkill += 1;
return { name: "jyotish-birth-time-rectification" };
},
async generate() {
calls.generate += 1;
return { text: NEW_REPLY };
},
...overrides,
};
return { agent, calls };
}
function validAccounting() {
return fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({ turns: completedTurns() }),
regenerate_agentic_rectification_turn: () => ({
ok: true,
turn_id: TURN_ID,
assistant_message: NEW_REPLY,
idempotent: false,
}),
});
}
function options(
accounting: ReturnType<typeof fakeAccounting>["client"],
agent: RectificationRegenerationAgent,
overrides: Partial<Parameters<typeof regenerateV9AssistantTurn>[0]> = {},
) {
return {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
turnId: TURN_ID,
requestId: REQUEST_ID,
accounting,
agent,
...overrides,
};
}
async function rejectsWithCode(promise: Promise<unknown>, code: string) {
await assert.rejects(promise, (error: unknown) => (
error instanceof RectificationToolServiceError && error.code === code
));
}
test("regeneration replaces the latest completed Assistant text in place", async () => {
const accounting = validAccounting();
const { agent, calls } = fakeAgent();
const result = await regenerateV9AssistantTurn(options(accounting.client, agent));
assert.deepEqual(result, {
ok: true,
turnId: TURN_ID,
assistantMessage: NEW_REPLY,
idempotent: false,
});
assert.equal(calls.getSkill, 1);
assert.equal(calls.generate, 1);
assert.deepEqual(accounting.calls.map((call) => call.fn), [
"get_agentic_rectification_turn_regeneration",
"get_agentic_rectification_case_dossier",
"regenerate_agentic_rectification_turn",
]);
const update = accounting.calls.at(-1);
assert.equal(update?.args.p_assistant_message, NEW_REPLY);
assert.equal(accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn"), false);
});
test("same request id returns the stored replacement without invoking the Agent again", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => ({
ok: true,
turn_id: TURN_ID,
assistant_message: NEW_REPLY,
idempotent: true,
}),
});
const { agent, calls } = fakeAgent();
const result = await regenerateV9AssistantTurn(options(accounting.client, agent));
assert.equal(result.idempotent, true);
assert.equal(calls.getSkill, 0);
assert.equal(calls.generate, 0);
assert.deepEqual(accounting.calls.map((call) => call.fn), [
"get_agentic_rectification_turn_regeneration",
]);
});
test("terminal cases cannot regenerate replies", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({
status: "closed",
turns: completedTurns(),
}),
});
const { agent, calls } = fakeAgent();
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent)),
"agentic_rectification_case_terminal",
);
assert.equal(calls.generate, 0);
});
test("case and session must remain exactly bound", async () => {
const accounting = validAccounting();
const { agent, calls } = fakeAgent();
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent, {
sessionId: "99999999-9999-4999-8999-999999999999",
})),
"agentic_rectification_case_session_mismatch",
);
assert.equal(calls.generate, 0);
});
test("only the latest completed Assistant turn can be regenerated", async () => {
const turns = [
{
id: SOURCE_TURN_ID,
role: "assistant",
text: "更早的回答",
status: "completed",
created_at: "2026-08-13T00:00:00.000Z",
},
...completedTurns(),
];
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({ turns }),
});
const { agent, calls } = fakeAgent();
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent, { turnId: SOURCE_TURN_ID })),
"agentic_rectification_turn_not_latest",
);
assert.equal(calls.generate, 0);
assert.equal(accounting.calls.some((call) => call.fn === "regenerate_agentic_rectification_turn"), false);
});
test("missing or empty completed Assistant text is rejected", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({
turns: [{
id: TURN_ID,
role: "assistant",
text: " ",
status: "completed",
created_at: "2026-08-13T01:00:00.000Z",
}],
}),
});
const { agent, calls } = fakeAgent();
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent)),
"agentic_rectification_turn_not_found",
);
assert.equal(calls.generate, 0);
});
test("an empty regenerated body never reaches the update RPC", async () => {
const accounting = validAccounting();
const { agent } = fakeAgent({
async generate() {
return { text: " " };
},
});
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent)),
"agentic_rectification_regeneration_empty",
);
assert.equal(accounting.calls.some((call) => call.fn === "regenerate_agentic_rectification_turn"), false);
});
+17 -2
View File
@@ -148,6 +148,18 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" });
assert.deepEqual(
safePublicEvent({ type: "run.completed", turnId: TURN_ID }),
{ type: "run.completed", turnId: TURN_ID },
);
assert.deepEqual(
safePublicEvent({ type: "answer.delta", text: "你好", turnId: TURN_ID }),
{ type: "answer.delta", text: "你好" },
);
assert.deepEqual(
safePublicEvent({ type: "run.completed", turnId: "not-a-uuid" }),
{ type: "run.completed" },
);
assert.deepEqual(
safePublicEvent({
type: "case.loaded",
@@ -234,7 +246,7 @@ function fakeAgentStream(chunks: Array<{ type: string; payload?: Record<string,
}
function runOptions(overrides: Partial<V9AgentRunOptions> = {}) {
const emitted: Array<{ type: string; text?: string }> = [];
const emitted: Array<{ type: string; text?: string; turnId?: string }> = [];
const billing = { reserved: 0, completed: 0, released: 0 };
const accounting = fakeAccounting({
...receiptHandlers,
@@ -285,7 +297,10 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "先确认一下:" },
]);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(
emitted.find((event) => event.type === "run.completed"),
{ type: "run.completed", turnId: TURN_ID },
);
assert.equal(emitted.some((event) => String(event.type).includes("reasoning")), false);
assert.equal(emitted.some((event) => String(event.type).includes("raw")), false);
});