Files
Jyotisha/frontend/tests/rectification-v9-stream.test.ts
T
Jesse_ChenandCursor a998b6ec53
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(rectification): stop unwritten-evidence claims and same-cluster dasha false conflicts (BUG-635–640)
Host only says 记下了 after a real write; Mastra schema rejections fail closed. Ledger year keys no longer drop quality probes, dual-dasha agreement is per cluster, width uses cluster span, and public house tables follow the inference minute.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 17:38:37 +08:00

1847 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import {
mapStreamChunkToActivity,
mapStreamChunkToPhase,
mapStreamChunkToThinking,
toPublicThinkingDelta,
safePublicEvent,
streamToolNames,
} from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import {
CASE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { messageContentHash } from "../src/lib/rectification-agentic/v9/message-origin.ts";
import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts";
type StreamChunk = {
type: string;
payload?: {
toolName?: unknown;
text?: unknown;
args?: unknown;
error?: unknown;
result?: unknown;
output?: unknown;
};
object?: unknown;
};
function chunk(type: string, payload?: Record<string, unknown>): StreamChunk {
return { type, ...(payload ? { payload } : {}) };
}
test("fullStream chunks map to the allowlisted NDJSON phases only", () => {
assert.equal(mapStreamChunkToPhase(chunk("start") as never), null);
assert.equal(
mapStreamChunkToPhase(chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }) as never),
null,
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-result", { toolName: "skill" }) as never),
{ type: "skill.bound" },
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-call", { toolName: "rectification-compare-candidates" }) as never),
{ type: "candidates.comparing", tool: "rectification-compare-candidates" },
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-result", {
toolName: "rectification-compare-candidates",
result: {
executed_methods: [
"d1-rashi",
"vimshottari-dasha",
"internal-secret-technique",
"d1-rashi",
],
birth_context: { birth_date: "1997-08-08" },
event_contribution_matrix: { secret: true },
rule_ids: ["internal-rule"],
},
}) as never),
{
type: "candidates.updated",
tool: "rectification-compare-candidates",
methods: ["d1-rashi", "vimshottari-dasha"],
},
);
assert.equal(
mapStreamChunkToPhase(chunk("tool-call", { toolName: "rectification-read-case" }) as never),
null,
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-result", {
toolName: "rectification-read-case",
result: { birth_context: { birth_date: "1997-08-08", latitude: 36.4 } },
}) as never),
{ type: "case.loaded", tool: "rectification-read-case" },
);
assert.equal(
mapStreamChunkToPhase(chunk("text-delta", { text: "你好" }) as never),
null,
);
assert.equal(mapStreamChunkToPhase(chunk("finish") as never), null);
assert.equal(mapStreamChunkToPhase(chunk("error", { error: new Error("boom") }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("abort") as never), null);
});
test("every public rectification tool maps its real lifecycle to public activity", () => {
for (const tool of PUBLIC_RECTIFICATION_TOOLS) {
assert.deepEqual(
mapStreamChunkToActivity(chunk("tool-call", {
toolName: tool,
args: { caseId: CASE_ID, birth_date: "1997-08-08", scores: [99] },
}) as never),
{ type: "tool.activity", tool, status: "started" },
);
assert.deepEqual(
mapStreamChunkToActivity(chunk("tool-result", {
toolName: tool,
result: {
executed_methods: ["d1-rashi", "private-method", "d1-rashi"],
birth_context: { birth_date: "1997-08-08" },
scores: [99],
},
}) as never),
{ type: "tool.activity", tool, status: "completed", methods: ["d1-rashi"] },
);
assert.deepEqual(
mapStreamChunkToActivity(chunk("tool-error", {
toolName: tool,
args: { caseId: CASE_ID, scores: [99] },
error: new Error("private provider error"),
}) as never),
{ type: "tool.activity", tool, status: "failed" },
);
}
assert.equal(mapStreamChunkToActivity(chunk("tool-call", { toolName: "skill" }) as never), null);
assert.equal(mapStreamChunkToActivity(chunk("tool-error", { toolName: "private-tool" }) as never), null);
assert.equal(mapStreamChunkToActivity(chunk("text-delta", { text: "x" }) as never), null);
});
test("evidence-batch rescore methods surface on the public activity event", () => {
assert.deepEqual(
mapStreamChunkToActivity(chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: {
accepted_count: 1,
rescore: {
status: "completed",
executed_methods: ["d1-rashi", "d10-dashamsa", "private-method"],
},
},
}) as never),
{
type: "tool.activity",
tool: "rectification-record-evidence-batch",
status: "completed",
methods: ["d1-rashi", "d10-dashamsa"],
},
);
});
test("reasoning, raw payloads, provider metadata and step internals never map to the answer channel", () => {
assert.equal(mapStreamChunkToPhase(chunk("reasoning-start", { id: "r1" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "内部推理" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-end") as never), null);
assert.equal(mapStreamChunkToPhase(chunk("raw", { payload: { secret: true } }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("step-start", { messageId: "m1" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("step-finish", { output: { text: "x" } }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("response-metadata", { signature: "s" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("source", { title: "t" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("file", { mimeType: "text/plain" }) as never), null);
});
test("Chinese thinking stays internal and is never a public stream event", () => {
assert.deepEqual(
mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对升学年份。" }) as never),
{ type: "thinking.delta", text: "先核对升学年份。" },
);
assert.equal(
mapStreamChunkToThinking(chunk("reasoning-delta", {
text: "The proposedKind value was rejected",
}) as never),
null,
);
assert.equal(
toPublicThinkingDelta("The proposedKind value was rejected because education is invalid"),
null,
);
assert.equal(safePublicEvent({ type: "thinking.delta", text: "先核对升学年份。" }), null);
assert.equal(safePublicEvent({
type: "thinking.delta",
text: "先核对升学年份。",
turnId: TURN_ID,
args: { caseId: CASE_ID },
}), null);
});
test("streamToolNames exposes only allowlisted rectification tools", () => {
assert.deepEqual(streamToolNames(chunk("tool-call", { toolName: "rectification-read-case" }) as never), ["rectification-read-case"]);
assert.deepEqual(streamToolNames(chunk("tool-call", { toolName: "skill" }) as never), []);
assert.deepEqual(streamToolNames(chunk("tool-call", { toolName: "rectification-gate" }) as never), []);
assert.deepEqual(streamToolNames(chunk("text-delta", { text: "x" }) as never), []);
});
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(
safePublicEvent({ type: "answer.delta", text: "你好", replace: true }),
{ type: "answer.delta", text: "你好", replace: true },
);
assert.deepEqual(
safePublicEvent({ type: "answer.delta", text: "你好", replace: false }),
{ type: "answer.delta", text: "你好" },
);
assert.equal(safePublicEvent({ type: "thinking.delta", text: "先核对升学" }), null);
assert.deepEqual(
safePublicEvent({ type: "activity.changed", activity: "reading_case" }),
{ type: "activity.changed", activity: "reading_case" },
);
assert.deepEqual(safePublicEvent({ type: "attempt.reset" }), { type: "attempt.reset" });
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",
tool: "rectification-read-case",
text: "1997-08-08 河北省邯郸市",
methods: ["d1-rashi"],
birth_context: { latitude: 36.4 },
}),
{ type: "case.loaded", tool: "rectification-read-case" },
);
assert.deepEqual(
safePublicEvent({
type: "tool.activity",
tool: "rectification-compare-candidates",
status: "completed",
methods: ["d10-dashamsa", "private-method", "d10-dashamsa"],
args: { caseId: CASE_ID },
error: "private provider error",
birth_context: { birth_date: "1997-08-08" },
scores: [99],
}),
{
type: "tool.activity",
tool: "rectification-compare-candidates",
status: "completed",
methods: ["d10-dashamsa"],
},
);
assert.deepEqual(
safePublicEvent({
type: "tool.activity",
tool: "rectification-read-case",
status: "started",
methods: ["d1-rashi"],
result: { private: true },
}),
{ type: "tool.activity", tool: "rectification-read-case", status: "started" },
);
assert.deepEqual(
safePublicEvent({
type: "tool.activity",
tool: "rectification-read-case",
status: "failed",
error: "private provider error",
}),
{ type: "tool.activity", tool: "rectification-read-case", status: "failed" },
);
assert.equal(safePublicEvent({ type: "tool.activity", tool: "private-tool", status: "started" }), null);
assert.equal(safePublicEvent({ type: "tool.activity", tool: "rectification-read-case", status: "pending" }), null);
assert.deepEqual(
safePublicEvent({
type: "candidates.updated",
tool: "rectification-compare-candidates",
methods: ["d10-dashamsa", "private-method", "d10-dashamsa"],
rule_ids: ["private-rule"],
}),
{
type: "candidates.updated",
tool: "rectification-compare-candidates",
methods: ["d10-dashamsa"],
},
);
assert.equal(safePublicEvent({ type: "provider.reasoning", text: "内部" }), null);
assert.equal(safePublicEvent({ type: "tool.payload", text: "秘密" }), null);
assert.equal(safePublicEvent({ type: "raw" }), null);
assert.equal(safePublicEvent(null), null);
assert.deepEqual(
safePublicEvent({ type: "error", code: "skill_identity_unverifiable", message: "请先采用当前 Skill" }),
{ type: "error", code: "skill_identity_unverifiable", message: "请先采用当前 Skill" },
);
assert.deepEqual(
safePublicEvent({ type: "error", code: "billing_unavailable", message: "当前服务的计费配置不可用,请联系支持人员。" }),
{
type: "error",
code: "billing_unavailable",
message: "当前服务的计费配置不可用,请联系支持人员。",
},
);
assert.equal(safePublicEvent({ type: "error", code: "private_error", message: "secret" }), null);
});
type FakeStreamResult = {
fullStream: AsyncIterable<{ type: string; payload?: Record<string, unknown> }>;
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
};
function fakeAgentStream(chunks: Array<{ type: string; payload?: Record<string, unknown> }>) {
return {
stream: async () => ({
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
}) as FakeStreamResult,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
function runOptions(overrides: Partial<V9AgentRunOptions> = {}) {
const emitted: Array<{ type: string; text?: string; turnId?: string }> = [];
const billing = { reserved: 0, completed: 0, released: 0 };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const optionsValue: V9AgentRunOptions = {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "2016年9月离开家去北京工作",
modelName: "gpt-4o-mini",
accounting: accounting.client,
billing: {
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
complete: async () => { billing.completed += 1; return true; },
release: async () => { billing.released += 1; return true; },
},
emit: (event) => { emitted.push(event); },
buildAgent: async () => fakeAgentStream([]) as never,
...overrides,
};
return { options: optionsValue, emitted, billing };
}
test("request-aware append receives the caller request id", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("tool-result", { toolName: "skill" }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "已记录" }),
chunk("finish"),
]) as never,
});
await runV9AgentTurn(options);
const append = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(append?.args.p_request_id, options.requestId);
});
test("completed request replay returns persisted truth without rebuilding or settling", async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ status: "confirmed" }),
append_agentic_rectification_turn: () => ({
turn_id: TURN_ID,
status: "completed",
assistant_message: "这是已持久化的回答",
successful_attempt_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
should_execute: false,
already_in_progress: false,
idempotent: true,
}),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
return fakeAgentStream([]) as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.turnId, TURN_ID);
assert.equal(result.answerText, "这是已持久化的回答");
assert.equal(buildCount, 0);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 0 });
assert.deepEqual(emitted, [
{ type: "run.started" },
{ type: "answer.delta", text: "这是已持久化的回答" },
{ type: "run.completed", turnId: TURN_ID },
]);
assert.equal(
accounting.calls.some((call) => call.fn === "create_agentic_rectification_run_attempt"),
false,
);
});
test("pending request replay never rebuilds or releases the original reservation", async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({
turn_id: TURN_ID,
status: "pending",
assistant_message: null,
successful_attempt_id: null,
should_execute: false,
already_in_progress: true,
idempotent: true,
}),
});
const { options, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
return fakeAgentStream([]) as never;
},
});
await assert.rejects(
runV9AgentTurn(options),
(error: unknown) => error instanceof Error
&& error.message.includes("agentic_rectification_turn_in_progress"),
);
assert.equal(buildCount, 0);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 0 });
});
for (const finalizedStatus of ["failed", "retryable"] as const) {
test(`${finalizedStatus} request replay does not rebuild and releases only the current claim`, async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({
turn_id: TURN_ID,
status: finalizedStatus,
assistant_message: null,
successful_attempt_id: null,
should_execute: false,
already_in_progress: false,
idempotent: true,
}),
});
const { options, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
return fakeAgentStream([]) as never;
},
});
await assert.rejects(
runV9AgentTurn(options),
(error: unknown) => error instanceof Error
&& error.message.includes("agentic_rectification_turn_already_finalized"),
);
assert.equal(buildCount, 0);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
}
test("answer deltas stream in order and reasoning is never forwarded", async () => {
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("reasoning-start", { id: "r1" }),
chunk("reasoning-delta", { text: "我应该先……" }),
chunk("reasoning-end"),
chunk("text-delta", { text: "好的," }),
chunk("text-delta", { text: "先确认一下:" }),
chunk("raw", { payload: { tool_args: { secret: true } } }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
const deltas = emitted.filter((event) => event.type === "answer.delta");
assert.deepEqual(deltas, [
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "先确认一下:" },
]);
assert.equal(deltas.map((event) => event.text).join(""), "好的,先确认一下:");
assert.deepEqual(
emitted.filter((event) => event.type === "thinking.delta"),
[],
);
assert.equal(emitted.some((event) => event.type === "activity.changed"), 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);
});
test("English tool-retry narration never becomes the spoken answer", async () => {
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", {
toolName: "rectification-record-evidence-batch",
args: { caseId: CASE_ID, proposedKind: "education" },
}),
chunk("tool-error", {
toolName: "rectification-record-evidence-batch",
error: new Error("invalid_event_kind"),
}),
chunk("text-delta", {
text: "The proposedKind value was rejected. Retrying with education_start.",
}),
chunk("reasoning-delta", { text: "先改用升学开始。" }),
chunk("tool-call", {
toolName: "rectification-record-evidence-batch",
args: { caseId: CASE_ID, proposedKind: "education_start" },
}),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("text-delta", { text: "记下了,2016年9月上大学。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "记下了,2016年9月上大学。" }],
);
assert.deepEqual(
emitted.filter((event) => event.type === "thinking.delta"),
[],
);
const publicText = JSON.stringify(emitted);
assert.doesNotMatch(publicText, /The proposedKind value was rejected/);
assert.doesNotMatch(publicText, /invalid_event_kind/);
});
test("Chinese process self-talk after tools is thinking, not the spoken answer", async () => {
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", {
toolName: "rectification-record-evidence-batch",
args: { caseId: CASE_ID, proposedKind: "education_start" },
}),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("reasoning-delta", {
text: "用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。\n\n",
}),
chunk("reasoning-delta", {
text: "但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 year。\n\n",
}),
chunk("text-delta", {
text: "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?",
}),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta").map((event) => event.text).join(""),
"记下了,大约六岁入学小学。接下来你大概哪一年上的初中?",
);
const thinking = emitted
.filter((event) => event.type === "thinking.delta")
.map((event) => event.text)
.join("");
assert.equal(thinking, "");
assert.equal(emitted.some((event) => String(event.type).includes("reasoning")), false);
assert.equal(result.answerText, "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?");
});
test("process-only self-talk after a completed batch uses the host fallback answer", async () => {
// 原值: empty_stream → ok=false,不计费,run.failed
// 新值: batch completed 且无正文 → 记下了。,phases 含 answer.host_fallback,计费 complete 一次
// 原因: BUG-633 决策 1,证据已落库不得因模型没写正文整轮失败
let buildCount = 0;
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
return attemptStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", {
toolName: "rectification-record-evidence-batch",
args: { caseId: CASE_ID, proposedKind: "education_start" },
}),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("reasoning-delta", { text: processTalk }),
chunk("finish"),
], { inputTokens: 11, outputTokens: 12 }) as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 1);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "记下了。");
assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.doesNotMatch(JSON.stringify(emitted), /我需要用批量工具/);
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(finalizedTurn?.args.p_status, "completed");
assert.equal(finalizedTurn?.args.p_assistant_message, "记下了。");
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
const pinched = "**先看候选结构(本会话以代表性时间收口";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: pinched }),
chunk("finish", { stepResult: { reason: "length" } }),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "answer_truncated");
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.filter((event) => event.type === "run.completed").length, 0);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: pinched }],
);
const turnFinalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(turnFinalize?.args.p_assistant_message, null);
assert.equal(turnFinalize?.args.p_successful_attempt_id, null);
});
test("length after a stamped open_question fails closed without rewriting the answer", async () => {
const prompt = "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
}),
chunk("text-delta", { text: "实习和离职的时间点都记下了,谢谢。" }),
chunk("finish", { stepResult: { reason: "length" } }),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "answer_truncated");
assert.equal(result.answerText, "");
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "实习和离职的时间点都记下了,谢谢。" }],
);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("answer deltas and tool activity are published before billing settles", async () => {
let billingStarted = false;
const seenBeforeBilling: string[] = [];
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
billing: {
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
complete: async () => {
billingStarted = true;
billing.completed += 1;
return true;
},
release: async () => { billing.released += 1; return true; },
},
emit: (event) => {
if (!billingStarted && (event.type === "answer.delta" || event.type === "tool.activity" || event.type === "answer.composed")) {
seenBeforeBilling.push(event.type);
}
emitted.push(event);
},
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "先记下这件事。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.ok(seenBeforeBilling.includes("tool.activity"));
assert.ok(seenBeforeBilling.includes("answer.delta"));
assert.ok(seenBeforeBilling.includes("answer.composed"));
const deltaIndex = emitted.findIndex((event) => event.type === "answer.delta");
const billingIndex = emitted.findIndex((event) => event.type === "billing.settled");
assert.ok(deltaIndex >= 0 && billingIndex > deltaIndex);
});
test("half-failure never becomes settled history and releases usage", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "正在计算," }),
chunk("error", { error: new Error("provider failure") }),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.turnStatus, "retryable");
assert.equal(billing.released, 1);
assert.equal(billing.completed, 0);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
});
test("browser disconnect aborts the run, finalizes retryable and releases usage", async () => {
const controller = new AbortController();
const aborted = new Promise<void>((resolve) => {
controller.signal.addEventListener("abort", () => resolve(), { once: true });
});
const hanging = (async function* () {
yield chunk("start");
yield chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } });
yield chunk("tool-result", { toolName: "skill" });
yield chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } });
yield chunk("tool-result", { toolName: "rectification-read-case" });
yield chunk("text-delta", { text: "你好," });
// The provider stream hangs until the client disconnects.
await aborted;
})();
const { options, billing } = runOptions({
signal: controller.signal,
buildAgent: async () => ({
stream: async () => ({
fullStream: hanging,
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
}),
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
}) as never,
});
const pending = runV9AgentTurn(options);
setTimeout(() => controller.abort(), 30);
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.errorCode, "stream_aborted");
assert.equal(billing.released, 1);
});
test("empty stream with no write tools retries once then fails closed", async () => {
// 原值: 第一次 empty_stream 直接 failed,不发 attempt.reset
// 新值: 零公开写工具时 retryable,两轮都空才失败
// 原因: BUG-633 决策 2fe87a9ec 仍禁止在已完成写工具后把 empty_stream 放进 RETRYABLE_ERROR_CODES
let buildCount = 0;
const { options, emitted, billing } = runOptions({
buildAgent: async () => {
buildCount += 1;
return fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("finish"),
]) as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 2);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "empty_stream");
assert.equal(result.answerText, "");
assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
});
test("host fallback after a completed batch recaps the returned events", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: {
accepted_recaps: [
{ display_date_label: "2016年9月", event_phrase: "入学" },
{ display_date_label: "2020年6月", event_phrase: "毕业" },
],
},
}),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "记下了:2016年9月 入学、2020年6月 毕业。");
assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
const phases = accounting.calls
.filter((call) => call.fn === "insert_agentic_rectification_run_phase")
.map((call) => call.args.p_phase);
assert.ok(phases.includes("answer.host_fallback"));
});
test("empty stream without a write tool retries and the second attempt can succeed", async () => {
let buildCount = 0;
const { options, emitted, billing } = runOptions({
buildAgent: async () => {
buildCount += 1;
return buildCount === 1
? fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("finish"),
]) as never
: fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
// 原值: 「记下了这件事。」
// 新值: 「先继续核对。」
// 原因: BUG-635 把无写入的「记下了」收口成 evidence_not_written;本用例只锁 empty_stream 重试后成功。
chunk("text-delta", { text: "先继续核对。" }),
chunk("finish"),
]) as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 2);
assert.equal(result.ok, true);
assert.equal(result.answerText, "先继续核对。");
assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
assert.ok(!result.phases.includes("answer.host_fallback"));
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("length finish after a completed batch with no text uses the host fallback", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: {
accepted_recaps: [
{ display_date_label: "2018年7月", event_phrase: "入职" },
],
},
}),
chunk("finish", { stepResult: { reason: "length" } }),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "记下了:2018年7月 入职。");
assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("set-focus without a batch and no text fails empty_stream without retrying", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "empty_stream");
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("legacy Skill identity fails before billing reservation", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
get_agentic_rectification_skill_identity: () => {
throw new Error("agentic_rectification_legacy_skill_identity_unverifiable");
},
});
const { options, billing } = runOptions({ accounting: accounting.client });
await assert.rejects(
runV9AgentTurn(options),
(error: unknown) => error instanceof Error
&& error.message.includes("agentic_rectification_legacy_skill_identity_unverifiable"),
);
assert.equal(billing.reserved, 0);
});
test("Skill identity tool failures retain their safe public codes", () => {
assert.equal(
safeToolErrorCode(new Error("agentic_rectification_legacy_skill_identity_unverifiable")),
"legacy_skill_identity_unverifiable",
);
assert.equal(
safeToolErrorCode(new Error("agentic_rectification_skill_identity_missing")),
"skill_identity_missing",
);
});
test("execution receipts are persisted per turn (phases + tools)", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
const phases = accounting.calls
.filter((call) => call.fn === "insert_agentic_rectification_run_phase")
.map((call) => call.args.p_phase);
assert.ok(phases.includes("run.started"));
assert.ok(phases.includes("skill.bound"));
assert.ok(phases.includes("case.loaded"));
assert.ok(phases.includes("intent.classified"));
assert.ok(phases.includes("answer.composed"));
assert.ok(phases.includes("billing.settled"));
assert.ok(phases.includes("run.completed"));
// answer.delta and thinking.delta are never persisted per-delta.
assert.ok(!phases.includes("answer.delta"));
assert.ok(!phases.includes("thinking.delta"));
assert.deepEqual(result.toolsUsed, ["rectification-read-case"]);
});
const SECOND_ATTEMPT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
type AttemptFailure = "stream_aborted" | "stream_unfinished";
function attemptStream(
chunks: StreamChunk[],
usage: { inputTokens: number; outputTokens: number },
) {
return {
stream: async () => ({
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve(usage),
}),
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
function failedAttemptChunks(errorCode: AttemptFailure): StreamChunk[] {
const chunks: StreamChunk[] = [
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: "失败 attempt 的半截文本" }),
];
if (errorCode === "stream_aborted") {
chunks.push(chunk("error", { error: new Error("provider stream aborted") }));
}
return chunks;
}
function successfulAttemptChunks(): StreamChunk[] {
return [
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("text-delta", { text: "第二次 attempt 成功" }),
chunk("finish"),
];
}
for (const failureCode of ["stream_aborted", "stream_unfinished"] as const) {
test(`${failureCode} attempt is isolated and the second successful attempt exclusively commits public truth`, async () => {
let buildCount = 0;
const completedUsage: Array<{ inputTokens: number; outputTokens: number; durationMs: number }> = [];
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
billing: {
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
complete: async (usage) => { completedUsage.push(usage); billing.completed += 1; return true; },
release: async () => { billing.released += 1; return true; },
},
buildAgent: async () => {
buildCount += 1;
return buildCount === 1
? attemptStream(failedAttemptChunks(failureCode), { inputTokens: 901, outputTokens: 902 }) as never
: attemptStream(successfulAttemptChunks(), { inputTokens: 31, outputTokens: 17 }) as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 2);
assert.equal(result.ok, true);
assert.equal(result.answerText, "第二次 attempt 成功");
assert.deepEqual(result.toolsUsed, [
"rectification-read-case",
"rectification-record-evidence-batch",
]);
const resetAt = emitted.findIndex((event) => event.type === "attempt.reset");
assert.ok(resetAt >= 0);
assert.equal(
emitted.some((event) => event.type === "answer.delta" && event.text?.includes("失败 attempt")),
false,
);
assert.deepEqual(
emitted.slice(resetAt + 1).filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "第二次 attempt 成功" }],
);
assert.equal(
emitted.slice(resetAt + 1).some((event) =>
event.type === "tool.activity"
&& (event as { tool?: string }).tool === "rectification-record-evidence-batch"),
true,
);
assert.deepEqual(completedUsage.map(({ inputTokens, outputTokens }) => ({ inputTokens, outputTokens })), [
{ inputTokens: 31, outputTokens: 17 },
]);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
const attemptFinalizations = accounting.calls
.filter((call) => call.fn === "finalize_agentic_rectification_run_attempt")
.map((call) => ({
attemptId: call.args.p_attempt_id,
status: call.args.p_status,
errorCode: call.args.p_error_code,
usage: call.args.p_usage,
}));
assert.deepEqual(attemptFinalizations, [
{
attemptId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
status: "retryable",
errorCode: failureCode,
usage: { inputTokens: 0, outputTokens: 0 },
},
{
attemptId: SECOND_ATTEMPT_ID,
status: "completed",
errorCode: null,
usage: { inputTokens: 31, outputTokens: 17 },
},
]);
const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.deepEqual(finalizedTurn?.args, {
p_user_id: USER_ID,
p_case_id: CASE_ID,
p_turn_id: TURN_ID,
p_attempt_id: SECOND_ATTEMPT_ID,
p_status: "completed",
p_assistant_message: "第二次 attempt 成功",
p_successful_attempt_id: SECOND_ATTEMPT_ID,
});
const settledPhases = accounting.calls.filter((call) =>
call.fn === "insert_agentic_rectification_run_phase"
&& (call.args.p_phase === "billing.settled" || call.args.p_phase === "run.completed"));
assert.deepEqual(settledPhases.map((call) => call.args.p_attempt_id), [
SECOND_ATTEMPT_ID,
SECOND_ATTEMPT_ID,
]);
const billingSettledIndex = accounting.calls.findIndex((call) =>
call.fn === "insert_agentic_rectification_run_phase"
&& call.args.p_phase === "billing.settled");
const runCompletedIndex = accounting.calls.findIndex((call) =>
call.fn === "insert_agentic_rectification_run_phase"
&& call.args.p_phase === "run.completed");
const successfulAttemptFinalizeIndex = accounting.calls.findIndex((call) =>
call.fn === "finalize_agentic_rectification_run_attempt"
&& call.args.p_status === "completed");
const completedTurnFinalizeIndex = accounting.calls.findIndex((call) =>
call.fn === "finalize_agentic_rectification_turn"
&& call.args.p_status === "completed");
assert.ok(billingSettledIndex < runCompletedIndex);
assert.ok(runCompletedIndex < successfulAttemptFinalizeIndex);
assert.ok(successfulAttemptFinalizeIndex < completedTurnFinalizeIndex);
});
}
test("a failed set-focus does not reset the attempt or hide the terminal answer", async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: (_fn, args) => ({
turn_id: TURN_ID,
status: args.p_status,
idempotent: false,
}),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
return attemptStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("invalid_focus") }),
chunk("text-delta", { text: "主问题:请确认这段经历发生在哪个月?" }),
chunk("finish"),
], { inputTokens: 41, outputTokens: 23 }) as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 1);
assert.equal(result.ok, true);
assert.equal(result.turnStatus, "completed");
assert.equal(result.answerText, "主问题:请确认这段经历发生在哪个月?");
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "主问题:请确认这段经历发生在哪个月?" }],
);
assert.equal(
emitted.some((event) => event.type === "tool.activity"
&& (event as { tool?: string; status?: string }).tool === "rectification-set-focus"
&& (event as { tool?: string; status?: string }).status === "failed"
&& (event as { code?: string }).code === "duplicate_focus"),
true,
);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("does not retry set-focus with identical arguments", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: (_fn, args) => ({
turn_id: TURN_ID,
status: args.p_status,
idempotent: false,
}),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => attemptStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("invalid_focus") }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: "主问题:请确认这段经历发生在哪个月?" }),
chunk("finish"),
], { inputTokens: 43, outputTokens: 29 }) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "主问题:请确认这段经历发生在哪个月?");
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("duplicate compare after diagnostics does not reset and host-falls back when the model emits no text", async () => {
// 原值: empty_stream failed,不计费
// 新值: batch 已完成 → 主持人兜底成功;重复 compare 仍只记一次 started、不 attempt.reset
// 原因: BUG-633 决策 1BUG-368 重复 compare 不得 reset
const executedMethods = [
"ashtakavarga",
"d1-rashi",
"d10-dashamsa",
"shadbala",
"functional-benefic-malefic",
"arudha-pada",
];
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("tool-call", { toolName: "rectification-propose-evidence", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-propose-evidence" }),
chunk("tool-call", { toolName: "rectification-confirm-evidence", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-confirm-evidence" }),
chunk("tool-call", { toolName: "rectification-read-diagnostics", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-read-diagnostics",
result: { executed_methods: executedMethods },
}),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-compare-candidates",
result: { executed_methods: executedMethods },
}),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "记下了。");
assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(
emitted.filter((event) => event.type === "tool.activity"
&& (event as { tool?: string; status?: string }).tool === "rectification-compare-candidates"
&& (event as { tool?: string; status?: string }).status === "started").length,
1,
);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("an unclaimed V10 attempt never starts the model", async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
create_agentic_rectification_run_attempt: () => ({
attempt_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
status: "started",
should_execute: false,
already_in_progress: true,
idempotent: true,
}),
});
const { options, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
return fakeAgentStream([]) as never;
},
});
await assert.rejects(
runV9AgentTurn(options),
(error: unknown) => error instanceof Error
&& error.message.includes("agentic_rectification_attempt_in_progress"),
);
assert.equal(buildCount, 0);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(
accounting.calls.some((call) => call.fn === "finalize_agentic_rectification_run_attempt"),
false,
);
});
test("non-retryable attempt errors do not start a second attempt", async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
throw new Error("provider contract violation");
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 1);
assert.equal(result.ok, false);
assert.equal(result.turnStatus, "failed");
assert.equal(result.errorCode, "run_failed");
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.deepEqual(emitted, [
{ type: "run.started" },
{
type: "run.failed",
code: "run_failed",
recoverable: false,
message: "上游模型连接失败,状态已记录。",
},
]);
assert.equal(
accounting.calls.filter((call) => call.fn === "create_agentic_rectification_run_attempt").length,
1,
);
const attemptFinalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_run_attempt");
assert.equal(attemptFinalize?.args.p_status, "failed");
assert.equal(attemptFinalize?.args.p_error_code, "run_failed");
const turnFinalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(turnFinalize?.args.p_attempt_id, attemptFinalize?.args.p_attempt_id);
assert.equal(turnFinalize?.args.p_assistant_message, null);
assert.equal(turnFinalize?.args.p_successful_attempt_id, null);
});
test("thinking-mode tool_choice rejection fails the opening turn without a second identical attempt", async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
action: "opening",
message: null,
buildAgent: async () => {
buildCount += 1;
return {
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
stream: async () => {
throw new Error("Thinking mode does not support this tool_choice");
},
} as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 1);
assert.equal(result.ok, false);
assert.equal(result.turnStatus, "failed");
assert.equal(result.errorCode, "thinking_tool_choice_unsupported");
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.deepEqual(emitted, [
{ type: "run.started" },
{ type: "skill.bound" },
{
type: "run.failed",
code: "thinking_tool_choice_unsupported",
recoverable: false,
message: "本轮没有完成,状态已记录。",
},
]);
assert.equal(
accounting.calls.filter((call) => call.fn === "create_agentic_rectification_run_attempt").length,
1,
);
const attemptFinalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_run_attempt");
assert.equal(attemptFinalize?.args.p_error_code, "thinking_tool_choice_unsupported");
});
test("does not publish intermediate tool-step text as answer.delta", async () => {
const { options, emitted } = runOptions({
message: "2020年4月开始实习 6月转正 10月离职",
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "Let me set" }),
chunk("text-delta", { text: " _probe" }),
chunk("text-delta", { text: " _gain" }),
chunk("tool-call", {
toolName: "rectification-record-evidence-batch",
args: { caseId: CASE_ID },
}),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("text-delta", { text: "记下了,2020年4月开始实习。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "记下了,2020年4月开始实习。" }],
);
const publicText = JSON.stringify(emitted);
assert.doesNotMatch(publicText, /Let me set/);
assert.doesNotMatch(publicText, /_probe/);
assert.doesNotMatch(publicText, /_gain/);
assert.equal(emitted.some((event) => event.type === "thinking.delta"), false);
});
test("Chinese interview planning stays on reasoning-delta; the spoken answer is the model text-delta", async () => {
const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**只要个大概年份就行。";
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("reasoning-delta", {
text: [
"方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分)。",
"让我继续访谈,问一件能帮助区分候选的职业前事。",
"本轮对照了Gochara、D1 本命盘、D10 事业分盘。",
].join("\n\n"),
}),
chunk("text-delta", { text: spoken }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
// 旧:模型正文含追问原样落库 → 新:证据轮超过两句只留首句
// 原因:BUG-606 服务端裁剪,不靠提示词
const recap = "职业类型已经记下。";
assert.equal(result.answerText, recap);
const spokenDeltas = emitted.filter((event) => event.type === "answer.delta");
assert.equal(spokenDeltas[0]?.text, spoken);
assert.equal(spokenDeltas.at(-1)?.text, recap);
assert.equal((spokenDeltas.at(-1) as { replace?: boolean } | undefined)?.replace, true);
const publicText = JSON.stringify(emitted);
assert.doesNotMatch(publicText, /occupation_note/);
assert.doesNotMatch(publicText, /让我继续访谈/);
assert.doesNotMatch(publicText, /本轮对照了/);
assert.equal(emitted.some((event) => event.type === "thinking.delta"), false);
});
test("timeout after a stamped open_question fails closed without pasting the lock", async () => {
const prompt = "2016 年前后 · 高考或重要考试发挥明显失常、压力很大";
const { options, emitted, billing } = runOptions({
attemptTimeoutMs: 40,
buildAgent: async () => ({
stream: async (
_messages: unknown,
streamOptions: { abortSignal?: AbortSignal },
) => ({
fullStream: (async function* () {
yield chunk("start");
yield chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } });
yield chunk("tool-result", { toolName: "skill" });
yield chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } });
yield chunk("tool-result", { toolName: "rectification-read-case" });
yield chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } });
yield chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
});
await new Promise<void>((resolve) => {
const signal = streamOptions.abortSignal;
if (!signal || signal.aborted) {
resolve();
return;
}
signal.addEventListener("abort", () => resolve(), { once: true });
});
})(),
totalUsage: Promise.resolve({ inputTokens: 11, outputTokens: 7 }),
}),
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "run_timeout");
assert.equal(result.answerText, "");
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.deepEqual(emitted.filter((event) => event.type === "answer.delta"), []);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("open_question text deltas remain the model answer instead of being rewritten", async () => {
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
}),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("text-delta", { text: "好的," }),
chunk("text-delta", { text: "毕业这条也记下了。" }),
chunk("text-delta", { text: "\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "好的,毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?");
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "毕业这条也记下了。" },
{ type: "answer.delta", text: "\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?" },
],
);
assert.match(JSON.stringify(emitted.filter((event) => event.type === "answer.delta")), /入学考试/);
});
test("persisted choice prompt does not rewrite a competing model follow-up", async () => {
const spoken = "好的,2020 年 6 月毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试,你当时发挥明显失常、或者压力特别大,有没有发生过?";
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
}),
chunk("text-delta", { text: spoken }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, spoken);
assert.match(result.answerText, /入学考试/);
assert.doesNotMatch(result.answerText, /有没有明显入职/);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: spoken }],
);
});
test("persisted choice card does not rewrite a matching year-locked follow-up", async () => {
const spoken = "好,实习和离职都记下了。\n\n2023 年前后,你有没有入职或者职责明显加重过?";
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: {
accepted_count: 1,
open_question: { prompt: "2023 年前后 · 入职、升职或职责明显加重" },
},
}),
chunk("text-delta", { text: spoken }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, spoken);
assert.match(result.answerText, /你有没有入职或者职责明显加重过/);
assert.doesNotMatch(result.answerText, /有没有明显入职、升职或职责明显加重/);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: spoken }],
);
});
test("model terminal text-delta is the reply even when Case evidence is available", async () => {
const spoken = "职业已经记下。你入职大概是哪一年?说个年份就行。";
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: spoken }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
// 旧:模型正文含入职追问原样落库 → 新:证据轮超过两句只留首句
// 原因:BUG-606 服务端裁剪
const recap = "职业已经记下。";
assert.equal(result.answerText, recap);
const spokenDeltas = emitted.filter((event) => event.type === "answer.delta");
assert.equal(spokenDeltas[0]?.text, spoken);
assert.equal(spokenDeltas.at(-1)?.text, recap);
assert.doesNotMatch(result.answerText, /已经记下:/);
assert.doesNotMatch(result.answerText, /2016年9月离家去北京开始工作/);
});
test("does not reset the whole attempt after duplicate_focus", async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: (_fn, args) => ({
turn_id: TURN_ID,
status: args.p_status,
idempotent: false,
}),
});
const { options, emitted } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
return attemptStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("duplicate_focus") }),
chunk("text-delta", { text: "已经记下实习,接下来对一下考试那年。" }),
chunk("finish"),
], { inputTokens: 11, outputTokens: 12 }) as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 1);
assert.equal(result.ok, true);
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.equal(
result.toolsUsed.filter((name) => name === "rectification-record-evidence-batch").length,
1,
);
});
test("persists message origin for every user turn", async () => {
const message = "2020年4月开始实习 6月转正 10月离职";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
accounting: accounting.client,
message,
messageOrigin: "typed",
clientActionId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "记下了,2020年4月开始实习。" }),
chunk("finish"),
]) as never,
});
await runV9AgentTurn(options);
const origin = accounting.calls.find((call) => call.fn === "record_agentic_rectification_turn_origin");
assert.deepEqual(origin?.args, {
p_user_id: USER_ID,
p_case_id: CASE_ID,
p_turn_id: TURN_ID,
p_origin: "typed",
p_client_action_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
p_content_hash: messageContentHash(message),
});
});
test("does not create a 2002 user message from a 2020 assistant suggestion", async () => {
const message = "2020年4月开始实习 6月转正 10月离职";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = runOptions({
accounting: accounting.client,
message,
messageOrigin: "typed",
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "202" }),
chunk("text-delta", { text: "0" }),
chunk("text-delta", { text: " Let me ask about 2002" }),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("text-delta", { text: "记下了,2020年4月开始实习。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
const origin = accounting.calls.find((call) => call.fn === "record_agentic_rectification_turn_origin");
assert.equal(origin?.args.p_origin, "typed");
assert.equal(origin?.args.p_content_hash, messageContentHash(message));
const publicText = JSON.stringify(emitted);
assert.doesNotMatch(publicText, /2002 年发生什么了/);
assert.doesNotMatch(result.answerText, /2002/);
assert.equal(result.answerText, "记下了,2020年4月开始实习。");
});