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"; import { bindSpokenToOpenQuestion, openQuestionPromptFromToolResult, } from "../src/lib/rectification-agentic/v9/turn-narration.ts"; import { createRectificationActivityReceiptState, receiptFromRectificationActivityState, reduceRectificationActivityReceipt, } from "../src/lib/rectification-activity-receipt.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): 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.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.equal(safePublicEvent({ type: "error", code: "private_error", message: "secret" }), null); }); type FakeStreamResult = { fullStream: AsyncIterable<{ type: string; payload?: Record }>; totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>; }; function fakeAgentStream(chunks: Array<{ type: string; payload?: Record }>) { 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 = {}) { 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("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: "好的,先确认一下:" }, ]); 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 tools is replaced by server narration, not retried", async () => { 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.match(result.answerText, /已经记下|请继续说下一件/); assert.equal(emitted.some((event) => event.type === "attempt.reset"), false); assert.doesNotMatch(JSON.stringify(emitted), /我需要用批量工具/); const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn"); assert.equal(finalizedTurn?.args.p_status, "completed"); 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("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((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 uses server narration instead of retrying the attempt", 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("finish"), ]) as never, }); const result = await runV9AgentTurn(options); assert.equal(result.ok, true); assert.match(result.answerText, /已经记下|请继续说下一件/); assert.equal(billing.completed, 1); assert.equal(emitted.some((event) => event.type === "attempt.reset"), false); assert.equal(emitted.some((event) => event.type === "run.completed"), true); }); 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 still completes with server narration", async () => { 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.match(result.answerText, /已经记下|请继续说下一件/); 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); assert.equal(result.answerText, spoken); assert.deepEqual( emitted.filter((event) => event.type === "answer.delta"), [{ type: "answer.delta", text: spoken }], ); 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 still completes without pasting the lock", async () => { const prompt = "2016 年前后 · 高考或重要考试发挥明显失常、压力很大"; const { options, emitted } = 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((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, true); assert.equal(result.errorCode, null); assert.equal(result.answerText, "记下了。"); assert.doesNotMatch(result.answerText, /有没有/); assert.equal(emitted.some((event) => event.type === "run.failed"), false); assert.equal(emitted.some((event) => event.type === "run.completed"), true); assert.deepEqual( emitted.filter((event) => event.type === "answer.delta"), [{ type: "answer.delta", text: "记下了。" }], ); }); test("persisted choice prompt replaces a competing model follow-up without a topic denylist", async () => { const spoken = "好的,2020 年 6 月毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试,你当时发挥明显失常、或者压力特别大,有没有发生过?"; const prompt = "2023 年前后 · 入职、升职或职责明显加重"; assert.equal( bindSpokenToOpenQuestion(spoken, prompt), "好的,2020 年 6 月毕业这条也记下了。", ); assert.equal(openQuestionPromptFromToolResult({ type: "tool-result", payload: { toolName: "rectification-record-evidence-batch", result: { open_question: { prompt } } }, }), prompt); 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, "好的,2020 年 6 月毕业这条也记下了。"); assert.doesNotMatch(result.answerText, /入学考试/); assert.doesNotMatch(result.answerText, /有没有明显入职/); assert.deepEqual( emitted.filter((event) => event.type === "answer.delta"), [{ type: "answer.delta", text: result.answerText }], ); }); test("year-locked agent follow-up is kept instead of the server template", async () => { const spoken = "好,实习和离职都记下了。\n\n2023 年前后,你有没有入职或者职责明显加重过?"; const prompt = "2023 年前后 · 入职、升职或职责明显加重"; assert.equal(bindSpokenToOpenQuestion(spoken, prompt), 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-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("lock-only prompt is not spliced into speech", () => { const lock = "2023 年前后 · 入职、升职或职责明显加重"; assert.equal(bindSpokenToOpenQuestion("", lock), ""); assert.equal(bindSpokenToOpenQuestion("记下了。", lock), "记下了。"); }); test("legacy full-sentence lock remains a last-resort spoken fallback", () => { const spoken = "好的。\n\n2016 年高考发挥失常过吗?"; const prompt = "2023 年前后,有没有明显入职、升职或职责明显加重?"; assert.equal(bindSpokenToOpenQuestion(spoken, prompt), `好的。\n\n${prompt}`); }); test("model terminal text-delta is the reply even when Case narration could be composed", 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); assert.equal(result.answerText, spoken); assert.deepEqual( emitted.filter((event) => event.type === "answer.delta"), [{ type: "answer.delta", text: spoken }], ); 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月开始实习。"); });