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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user