fix(bridge): gate tool-input-available on clean execution — invalid tool inputs never reach the dock

Live crash (modal-realism session): GLM double-encoded the quiz options
array as a JSON STRING inside the tool-call arguments. The bridge emitted
tool-input-available at toolcall_end — BEFORE validation runs — so the
client briefly saw the malformed quiz as a pending question and the dock
crashed in normalizeOptions ((string).map). The later tool-input-error
chunk could not revive the tree.

The old SDK gated this with InvalidToolInputError: validation ran before
the input-available chunk. The bridge now holds the part in
input-streaming until tool_execution_end and only then emits
tool-input-available (clean) or tool-input-error (invalid, structural
JSON Schema failure or the zod re-run inside execute).

Server-side behavior was already correct (the record shows the error
part + a successful model retry) — this is purely the wire-ordering hole.

Pinned in chat-flow.test.ts: the refinement-layer retry (correctAnswer
mismatch) and the exact live structural shape (string options) both
assert the invalid call never becomes input-available on the wire.

Gates: tsc, lint, build, 306/306.
This commit is contained in:
amosblomqvist
2026-09-12 03:59:43 +03:00
parent 2d717fd932
commit 6af7a38684
2 changed files with 89 additions and 19 deletions
+65 -1
View File
@@ -263,7 +263,25 @@ describe("teacher chat flow (scripted model)", () => {
const chat = chatStore.createChat("Test: bad quiz retry");
createdChats.push(chat.id);
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
// WIRE pin (the live modal-realism crash): the invalid call must NEVER
// become input-available — the dock mounts on input-available parts and
// crashed on a malformed quiz before tool-input-error could replace it.
// The SDK gated this with InvalidToolInputError; the bridge gates it on
// clean tool_execution_end.
assert.ok(
!body.includes('"type":"tool-input-available","toolCallId":"call-bad"'),
"the invalid call never reached input-available on the wire",
);
assert.ok(
body.includes('"type":"tool-input-error","toolCallId":"call-bad"'),
"the invalid call surfaced as tool-input-error",
);
assert.ok(
body.includes('"type":"tool-input-available","toolCallId":"call-good"'),
"the valid retry DID become input-available",
);
// The model was re-prompted (the error did not end the turn)…
assert.equal(calls.length, 2, "the invalid call triggered a second model prompt");
@@ -291,6 +309,52 @@ describe("teacher chat flow (scripted model)", () => {
assert.equal(pending?.toolCallId, "call-good", "the RETRIED quiz is the pending one");
assert.deepEqual(pending?.input, QUIZ_INPUT);
});
it("a structurally invalid quiz (the live double-encoded options string) never reaches the dock and retries clean", async () => {
// The exact live modal-realism crash shape: GLM occasionally serializes
// a nested array as a JSON STRING inside the arguments. The structural
// layer (JSON Schema: options must be array) rejects the call at the
// boundary — it must surface as tool-input-error, never as a pending
// question, and the model's retry must become the pending quiz.
const STRINGY_QUIZ_INPUT = {
question: "Lewis's indexical analysis of 'actual' — which is it?",
options: JSON.stringify([
{ label: "Like 'I'", value: "indexical" },
{ label: "Like a title", value: "unique" },
]), // ← the bug: a string, not an array
correctAnswer: "indexical",
explanation: "Indexical: every world is actual to its own inhabitants.",
};
const steps: FauxResponseStep[] = [
() => fauxAssistantMessage([fauxToolCall("quiz", STRINGY_QUIZ_INPUT, { id: "call-stringy" })], { stopReason: "toolUse" }),
() => fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "call-clean" })], { stopReason: "toolUse" }),
];
const { engine } = scriptedTeacher(() => steps);
const chat = chatStore.createChat("Test: string options");
createdChats.push(chat.id);
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
assert.ok(
!body.includes('"type":"tool-input-available","toolCallId":"call-stringy"'),
"the structurally invalid quiz never became input-available on the wire",
);
assert.ok(
body.includes('"type":"tool-input-error","toolCallId":"call-stringy"'),
"the invalid quiz surfaced as tool-input-error",
);
const saved = chatStore.getChat(chat.id)!;
const failed = saved.messages[1]!.parts.find(
(p) => (p as { toolCallId?: string }).toolCallId === "call-stringy",
);
assert.equal((failed as { state?: string }).state, "output-error", "persisted as an error part");
assert.match(String((failed as { errorText?: string }).errorText), /options/);
const pending = findPendingToolCall(saved.messages);
assert.equal(pending?.toolCallId, "call-clean", "the retried quiz is the pending one");
});
});
describe("chat store", () => {