fix(web): keep thinking off the spoken consult and rectification answer

Enumerate evidence kinds so education cannot be proposed as a kind, and stream Chinese thinking on a separate channel that collapses when the reply arrives.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-21 21:30:55 +08:00
co-authored by Cursor
parent 56577504d8
commit 4e247c112e
27 changed files with 465 additions and 73 deletions
+86 -3
View File
@@ -4,6 +4,8 @@ import test from "node:test";
import {
mapStreamChunkToActivity,
mapStreamChunkToPhase,
mapStreamChunkToThinking,
toPublicThinkingDelta,
safePublicEvent,
streamToolNames,
} from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
@@ -132,7 +134,7 @@ test("every public rectification tool maps its real lifecycle to public activity
assert.equal(mapStreamChunkToActivity(chunk("text-delta", { text: "x" }) as never), null);
});
test("reasoning, raw payloads, provider metadata and step internals never map", () => {
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);
@@ -144,6 +146,36 @@ test("reasoning, raw payloads, provider metadata and step internals never map",
assert.equal(mapStreamChunkToPhase(chunk("file", { mimeType: "text/plain" }) as never), null);
});
test("Chinese thinking maps to a public thinking channel and English process talk does not", () => {
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.deepEqual(
safePublicEvent({ type: "thinking.delta", text: "先核对升学年份。" }),
{ type: "thinking.delta", text: "先核对升学年份。" },
);
assert.deepEqual(
safePublicEvent({
type: "thinking.delta",
text: "先核对升学年份。",
turnId: TURN_ID,
args: { caseId: CASE_ID },
}),
{ type: "thinking.delta", text: "先核对升学年份。" },
);
});
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), []);
@@ -153,6 +185,7 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(safePublicEvent({ type: "thinking.delta", text: "先核对升学" }), { type: "thinking.delta", text: "先核对升学" });
assert.deepEqual(safePublicEvent({ type: "attempt.reset" }), { type: "attempt.reset" });
assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" });
assert.deepEqual(
@@ -441,6 +474,10 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "先确认一下:" },
]);
assert.deepEqual(
emitted.filter((event) => event.type === "thinking.delta"),
[{ type: "thinking.delta", text: "我应该先……" }],
);
assert.deepEqual(
emitted.find((event) => event.type === "run.completed"),
{ type: "run.completed", turnId: TURN_ID },
@@ -449,6 +486,50 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
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"),
[{ type: "thinking.delta", text: "先改用升学开始。" }],
);
const publicText = JSON.stringify(emitted);
assert.doesNotMatch(publicText, /The proposedKind value was rejected/);
assert.doesNotMatch(publicText, /invalid_event_kind/);
});
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
const pinched = "**先看候选结构(还不能确认唯一分钟";
const accounting = fakeAccounting({
@@ -662,8 +743,9 @@ test("execution receipts are persisted per turn (phases + tools)", async () => {
assert.ok(phases.includes("answer.composed"));
assert.ok(phases.includes("billing.settled"));
assert.ok(phases.includes("run.completed"));
// answer.delta is never persisted per-delta.
// 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"]);
});
@@ -872,7 +954,8 @@ test("a failed set-focus cannot complete a question turn even when the agent emi
assert.equal(result.answerText, "");
assert.deepEqual(result.toolsUsed, ["rectification-read-case", "rectification-set-focus"]);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.some((event) => event.type === "answer.delta"), true);
assert.equal(emitted.some((event) => event.type === "answer.delta"), false);
assert.equal(emitted.some((event) => event.type === "thinking.delta"), true);
assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.equal(