diff --git a/src/core/view.ts b/src/core/view.ts index 640e189..5eb1c78 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -129,7 +129,15 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState }; const findToolPart = (toolCallId: string): LhViewPart | undefined => { for (let i = out.length - 1; i >= 0; i--) { - for (const part of out[i]!.parts) { + // NEWEST-FIRST part scan within a group (the Lacan heal, 2026-10-04): + // a result pairs with the most RECENT call carrying its id. Unique + // ids (the uniquifier guarantees them for new turns) make scan order + // irrelevant; for legacy duplicate-id records (DeepSeek's per-response + // `call_0`) this routes each answer to its own call instead of letting + // a later result overwrite an earlier question's card. + const parts = out[i]!.parts; + for (let p = parts.length - 1; p >= 0; p--) { + const part = parts[p]!; if (part.type.startsWith("tool-") && part.toolCallId === toolCallId) return part; } } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 13c71e3..d8f2938 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -17,6 +17,7 @@ import { unregisterLiveAgent, } from "./jobs.ts"; import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts"; +import { collectToolCallIds } from "./pi/call-ids.ts"; import { effectiveSettings, stores } from "./stores.ts"; import { searchAvailable, searchProvider } from "./search.ts"; import { providerKeyPresent } from "./keys.ts"; @@ -379,6 +380,12 @@ export async function handleChatInput( registerLiveAgent(chatId, constructed); const handle = runPiTurn({ agent: constructed, + // THE LACAN FIX (2026-10-04): seed the uniquifier with every + // tool-call id the record already holds — a provider that resets + // ids per response (DeepSeek) then gets fresh unique ids for this + // turn's calls at the wire boundary, on the wire, in the record, + // and in the next turn's context alike. + usedToolCallIds: new Set(collectToolCallIds(merged)), // Stream-level failures are provider/transport internals — masked, // like the SDK defaults. Tool-input validation messages never // route through here: they ride the error-result path and reach the diff --git a/src/server/pi/call-ids.ts b/src/server/pi/call-ids.ts new file mode 100644 index 0000000..76b945b --- /dev/null +++ b/src/server/pi/call-ids.ts @@ -0,0 +1,212 @@ +import type { AgentEvent } from "@earendil-works/pi-agent-core"; + +/** + * TOOL-CALL ID UNIQUIFICATION (owner-approved fix, 2026-10-04 — the Lacan + * incident): some providers (DeepSeek via OpenRouter, observed live) return + * tool-call ids that reset every response — `call_0`, `call_1`, … — so a + * one-call-per-turn session names EVERY call `call_0`. Everything in this + * app keys on toolCallId (the view fold's call↔result join, the pending- + * question gate, answer routing, the option-shuffle seed), so cross-turn + * duplicates corrupted all of them at once: the fold applied the quiz + * answer to the ASK card, the ask question vanished, the order scrambled. + * + * THE LAW: ids are uniquified ONCE, at the wire boundary — this class sits + * inside runPiTurn, sees every AgentEvent before any view, and applies the + * same minting at settlement. The client, the persisted record, the dock, + * the answer flow, and the next turn's provider context (rebuilt from the + * record) all see exactly one set of unique ids. Provider context stays + * internally paired (call and result rewrite together), which the + * OpenAI-compatible APIs accept — pi itself synthesizes ids for providers + * that return none, so arbitrary-but-paired ids are already protocol-legal. + * History is NEVER rewritten: record messages pass through untouched, and + * pre-fix duplicate records heal at the fold (view.ts pairs results + * newest-first). + * + * SCHEME: a per-chat seen-set (seeded from the record at turn start) plus + * per-message tables. Streaming messages carry NO id (ids are stamped at + * persist), so tables key by OBJECT IDENTITY — the message_end event's + * object is reference-identical to the object in agent.state.messages + * (verified against pi-agent-core), and a WeakMap joins the two. A fresh + * table (epoch) opens at each assistant message_start; the update/end + * objects of that message attach to it; mints claim once per message, so + * the same id in a SECOND message of the turn (a retry step) gets its own + * mint. Colliding ids become `${orig}~2`, `~3`, … skipping used ones. + */ + +/** Structurally walk any message's content for tool-call blocks. */ +function callBlocks(message: unknown): Array<{ id: string } & Record> { + const content = (message as { content?: unknown } | undefined)?.content; + if (!Array.isArray(content)) return []; + return content.filter( + (b): b is { id: string } & Record => + (b as { type?: string } | null)?.type === "toolCall" && typeof (b as { id?: unknown }).id === "string", + ); +} + +/** The ids every consumer may already have seen: all call ids and result + * ids in a message list (the chat record — including its legacy dupes, + * which is the point: membership is what drives minting). */ +export function collectToolCallIds(messages: readonly unknown[]): string[] { + const ids: string[] = []; + for (const m of messages) { + const role = (m as { role?: string }).role; + if (role === "assistant") { + for (const b of callBlocks(m)) ids.push(b.id); + } else if (role === "toolResult") { + const id = (m as { toolCallId?: unknown }).toolCallId; + if (typeof id === "string") ids.push(id); + } + } + return ids; +} + +export class ToolCallIdUniquifier { + private readonly seen = new Set(); + /** assistant message object → its (origId → uniqueId) table. Weak: the + * agent's turn dies at settle; nothing here outlives it. */ + private readonly tables = new WeakMap>(); + /** The current assistant message's table — opened at its message_start, + * shared by its update/end objects, and the resolution scope for its + * tool_execution events (which fire after its message_end but before + * the next message_start). */ + private epoch: Map | null = null; + + constructor(usedIds: Iterable) { + for (const id of usedIds) this.seen.add(id); + } + + /** Keep `orig` if never used; otherwise mint an unused `${orig}~n`. */ + private claim(orig: string): string { + if (!this.seen.has(orig)) { + this.seen.add(orig); + return orig; + } + for (let n = 2; ; n++) { + const candidate = `${orig}~${n}`; + if (!this.seen.has(candidate)) { + this.seen.add(candidate); + return candidate; + } + } + } + + /** Register + rewrite one message's tool-call blocks (idempotent across + * the repeated partials of message_update). Returns a cloned message + * when any id changed, else the original object. */ + private remapMessageContent(message: unknown): unknown { + if ((message as { role?: string }).role !== "assistant") return message; + const blocks = callBlocks(message); + if (blocks.length === 0) return message; + + // Attach to the current epoch unless this object already has a table + // (a re-emitted update/end). A message event with no open epoch + // (defensive: a stream that skips message_start) opens one. + let table = this.tables.get(message as object); + if (!table) { + table = this.epoch ?? new Map(); + this.epoch = table; + this.tables.set(message as object, table); + } else { + this.epoch = table; + } + + let changed = false; + const content = (message as { content: unknown[] }).content.map((block) => { + if ((block as { type?: string }).type !== "toolCall") return block; + const orig = (block as { id?: unknown }).id; + if (typeof orig !== "string") return block; + let mapped = table!.get(orig); + if (mapped === undefined) { + mapped = this.claim(orig); + table!.set(orig, mapped); + } + if (mapped === orig) return block; + changed = true; + return { ...(block as object), id: mapped }; + }); + return changed ? { ...(message as object), content } : message; + } + + /** Every AgentEvent, ids uniquified. Structural and total: unknown + * event shapes pass through untouched. */ + event(event: AgentEvent): AgentEvent { + switch (event.type) { + case "message_start": { + const e = event as { message: { role?: string } }; + // A new assistant message opens a fresh epoch — this is what + // keeps a retried id in a LATER message from reusing the + // earlier message's mint. + if (e.message?.role === "assistant") this.epoch = new Map(); + return this.remapEventMessage(event); + } + case "message_update": + case "message_end": + return this.remapEventMessage(event); + case "tool_execution_start": + case "tool_execution_update": + case "tool_execution_end": { + const { toolCallId } = event as { toolCallId: string }; + const mapped = this.epoch?.get(toolCallId) ?? toolCallId; + if (mapped === toolCallId) return event; + return { ...(event as object), toolCallId: mapped } as AgentEvent; + } + case "turn_end": { + const e = event as { message: unknown; toolResults: Array<{ toolCallId?: string }> }; + const message = this.remapMessageContent(e.message); + const table = this.epoch; + const toolResults = table + ? e.toolResults.map((r) => + typeof r.toolCallId === "string" && table.has(r.toolCallId) + ? { ...r, toolCallId: table.get(r.toolCallId)! } + : r, + ) + : e.toolResults; + return { ...(e as object), message, toolResults } as unknown as AgentEvent; + } + case "agent_end": { + const e = event as { messages: unknown[] }; + return { ...(e as object), messages: this.remapSettled(e.messages) } as unknown as AgentEvent; + } + default: + return event; + } + } + + private remapEventMessage(event: AgentEvent): AgentEvent { + const e = event as { message: unknown }; + const remapped = this.remapMessageContent(e.message); + if (remapped === e.message) return event; + return { ...(event as object), message: remapped } as AgentEvent; + } + + /** The settle seam: the agent's final state, rewritten to the ids the + * wire already showed. Only THIS turn's messages rewrite (the ones + * registered by their event objects); prior-turn history — including + * legacy duplicate-id records — passes through verbatim. Tool results + * pair with the most recent registered assistant message (the loop's + * own structure: results follow their calls). */ + remapSettled(messages: readonly unknown[]): unknown[] { + let scope: Map | undefined; + return messages.map((m) => { + const role = (m as { role?: string }).role; + if (role === "assistant") { + const table = this.tables.get(m as object); + if (!table) { + // Prior-turn or unregistered: history is never rewritten. + scope = undefined; + return m; + } + scope = table; + return this.remapMessageContent(m); + } + if (role === "toolResult") { + const orig = (m as { toolCallId?: unknown }).toolCallId; + if (scope && typeof orig === "string" && scope.has(orig)) { + return { ...(m as object), toolCallId: scope.get(orig)! }; + } + return m; + } + return m; + }); + } +} diff --git a/src/server/pi/ui-stream.ts b/src/server/pi/ui-stream.ts index 4badd21..83f65d0 100644 --- a/src/server/pi/ui-stream.ts +++ b/src/server/pi/ui-stream.ts @@ -1,11 +1,15 @@ import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core"; import type { SteerMessage } from "../../core/contracts.ts"; import { noteSteerVisible } from "../jobs.ts"; +import { ToolCallIdUniquifier } from "./call-ids.ts"; /** * THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed - * to every view as VERBATIM pi AgentEvents — no translation, no remapping, no - * part assembly. The SSE body is the agent's own event stream: + * to every view as pi AgentEvents — no translation, no part assembly. The one + * sanctioned rewrite (the Lacan fix, 2026-10-04): tool-call IDs uniquify at + * this boundary when the provider resets them per response (DeepSeek's + * `call_0` every turn) — see call-ids.ts and PiTurnSpec.usedToolCallIds. + * The SSE body is the agent's own event stream: * * data: {"type":"agent_start"}\n\n * data: {"type":"message_update","message":{…},"assistantMessageEvent":{…}}\n\n @@ -57,6 +61,14 @@ export interface PiTurnSpec { * Receives the agent's FULL final state (native messages) — the caller * persists the slice past its own base count. */ onFinish: (messages: AgentMessage[], isAborted: boolean) => void; + /** Tool-call ids already used in this chat's record (THE LACAN FIX, + * owner-approved 2026-10-04): when present, a ToolCallIdUniquifier + * sits at this boundary — providers that reset ids every response + * (DeepSeek's `call_0` × every turn) get colliding ids reminted ONCE + * here, so wire views, the persisted record, the dock, answer + * routing, and the next turn's provider context all agree. Absent → + * verbatim passthrough (the protocol law below still holds). */ + usedToolCallIds?: ReadonlySet; } /** One view onto a running turn: push() replays/streams an event, done() @@ -82,7 +94,8 @@ export interface PiTurnHandle { } export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { - const { agent, onError, onFinish } = spec; + const { agent, onError, onFinish, usedToolCallIds } = spec; + const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined; let settled = false; let sawAbort = false; @@ -123,15 +136,14 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { // messages of its own (the prompt is composed per turn), so dropping // the role wholesale is exact; revisit if prompt-updates ever land. const settleWith = () => { - onFinish( - [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system"), - sawAbort, - ); + const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system"); + onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort); }; const handle = (event: AgentEvent) => { - // VERBATIM: every pi AgentEvent forwards as-is — no remapping. - send(event); + // THE LACAN FIX: ids uniquify HERE, before any view — the one seam + // every consumer downstream shares. No uniquifier → verbatim. + send(uniquifier ? uniquifier.event(event) : event); if (event.type === "message_end") { const m = event.message as { role?: string; stopReason?: string } | undefined; if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true; diff --git a/tests/call-ids.test.ts b/tests/call-ids.test.ts new file mode 100644 index 0000000..e4a8783 --- /dev/null +++ b/tests/call-ids.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { AgentEvent } from "@earendil-works/pi-agent-core"; +import { ToolCallIdUniquifier, collectToolCallIds } from "../src/server/pi/call-ids.ts"; + +/** + * THE LACAN INCIDENT (owner-approved fix, 2026-10-04): DeepSeek (via + * OpenRouter) returns tool-call ids that reset every response — a + * one-call-per-turn session names EVERY call `call_0`. The uniquifier sits + * at the wire boundary (runPiTurn): colliding ids remint once, and the + * same minting applies at settlement, so wire + record + next-turn context + * agree. These pins drive it as a pure unit: event sequences in, remapped + * events and settled messages out. + * + * REFERENCE-IDENTITY CONTRACT: streaming messages carry no ids (stamped + * at persist), so tables key by object identity — the message_end object + * is the object agent.state.messages holds (verified against + * pi-agent-core). The pins hold references the same way. + */ + +const assistantMsg = (id: string, callIds: string[]): any => ({ + id, + role: "assistant", + content: callIds.map((cid) => ({ type: "toolCall", id: cid, name: "quiz", arguments: {} })), +}); +const toolResultMsg = (callId: string): any => ({ + id: `r_${callId}`, + role: "toolResult", + toolCallId: callId, + content: [], + details: { kind: "quiz" }, +}); +const asEvent = (e: unknown): AgentEvent => e as AgentEvent; +const firstCallId = (e: AgentEvent): string => (e as any).message.content[0].id; + +describe("ToolCallIdUniquifier", () => { + it("unique ids pass through verbatim — the wire stays honest when the provider is", () => { + const u = new ToolCallIdUniquifier(["call_9"]); + const e = u.event(asEvent({ type: "message_end", message: assistantMsg("m1", ["toolu_abc"]) })); + assert.equal(firstCallId(e), "toolu_abc"); + }); + + it("a colliding id mints `orig~2` — on the message, the execution events, and the settle, identically", () => { + const u = new ToolCallIdUniquifier(["call_0"]); + const m1 = assistantMsg("m1", ["call_0"]); + // the streaming shape: start/update/end — same logical message + for (const t of ["message_start", "message_update", "message_end"]) { + const e = u.event(asEvent({ type: t, message: m1 })); + assert.equal(firstCallId(e), "call_0~2", `${t} shows the mint`); + } + const exec = u.event(asEvent({ type: "tool_execution_end", toolCallId: "call_0", toolName: "quiz", result: null, isError: false })) as { toolCallId: string }; + assert.equal(exec.toolCallId, "call_0~2"); + // settle: the SAME mint, paired call + result (m1 is the state object) + const settled = u.remapSettled([m1, toolResultMsg("call_0")]) as Array<{ + role: string; + content?: Array<{ id: string }>; + toolCallId?: string; + }>; + assert.equal(settled[0]!.content![0]!.id, "call_0~2"); + assert.equal(settled[1]!.toolCallId, "call_0~2"); + }); + + it("the same id in a SECOND message of the turn (a retry step) gets its own mint", () => { + const u = new ToolCallIdUniquifier(["call_0"]); + const m1 = assistantMsg("m1", ["call_0"]); + const m2 = assistantMsg("m2", ["call_0"]); + u.event(asEvent({ type: "message_end", message: m1 })); + // message_start opens the second message's epoch (the loop's shape) + u.event(asEvent({ type: "message_start", message: assistantMsg("m2", []) })); + assert.equal(firstCallId(u.event(asEvent({ type: "message_end", message: m2 }))), "call_0~3"); + // each result pairs with ITS message's call (the scope walk) + const settled = u.remapSettled([m1, toolResultMsg("call_0"), m2, toolResultMsg("call_0")]) as Array<{ + role: string; + content?: Array<{ id: string }>; + toolCallId?: string; + }>; + assert.equal(settled[0]!.content![0]!.id, "call_0~2"); + assert.equal(settled[1]!.toolCallId, "call_0~2"); + assert.equal(settled[2]!.content![0]!.id, "call_0~3"); + assert.equal(settled[3]!.toolCallId, "call_0~3"); + }); + + it("mint candidates skip ids already used (a provider that counts globally can't be ambushed)", () => { + const u = new ToolCallIdUniquifier(["call_0", "call_0~2"]); + assert.equal(firstCallId(u.event(asEvent({ type: "message_end", message: assistantMsg("m1", ["call_0"]) }))), "call_0~3"); + }); + + it("parallel calls in one message keep their distinct provider ids unless they collide", () => { + const u = new ToolCallIdUniquifier(["call_1"]); + const e = u.event(asEvent({ type: "message_end", message: assistantMsg("m1", ["call_0", "call_1"]) })) as unknown as { + message: { content: Array<{ id: string }> }; + }; + assert.equal(e.message.content[0]!.id, "call_0", "non-colliding sibling untouched"); + assert.equal(e.message.content[1]!.id, "call_1~2", "colliding sibling minted"); + }); + + it("agent_end carries the full state: prior-turn history verbatim, this turn remapped", () => { + const priorTurn = [assistantMsg("m0", ["call_0"]), toolResultMsg("call_0")]; // legacy dupes in the record + // seeded exactly as chat-handler seeds it: the record's ids + const u = new ToolCallIdUniquifier(collectToolCallIds(priorTurn)); + const m1 = assistantMsg("m1", ["call_0"]); + u.event(asEvent({ type: "message_end", message: m1 })); + const e = u.event(asEvent({ type: "agent_end", messages: [...priorTurn, m1, toolResultMsg("call_0")] })) as unknown as { + messages: Array<{ id?: string; role: string; content?: Array<{ id: string }>; toolCallId?: string }>; + }; + // history is NEVER rewritten (its dupes heal at the fold, view.ts) + assert.equal(e.messages[0]!.content![0]!.id, "call_0"); + assert.equal(e.messages[1]!.toolCallId, "call_0"); + // this turn's pair is the mint + assert.equal(e.messages[2]!.content![0]!.id, "call_0~2"); + assert.equal(e.messages[3]!.toolCallId, "call_0~2"); + }); + + it("turn_end remaps its message and toolResults together", () => { + const u = new ToolCallIdUniquifier(["call_0"]); + const m1 = assistantMsg("m1", ["call_0"]); + u.event(asEvent({ type: "message_end", message: m1 })); + const e = u.event(asEvent({ type: "turn_end", message: m1, toolResults: [toolResultMsg("call_0")] })) as unknown as { + message: { content: Array<{ id: string }> }; + toolResults: Array<{ toolCallId: string }>; + }; + assert.equal(e.message.content[0]!.id, "call_0~2"); + assert.equal(e.toolResults[0]!.toolCallId, "call_0~2"); + }); + + it("non-message events pass through untouched", () => { + const u = new ToolCallIdUniquifier([]); + assert.deepEqual(u.event({ type: "agent_start" }), { type: "agent_start" }); + }); +}); + +describe("collectToolCallIds (the seen-set seed)", () => { + it("gathers call ids and result ids from a record", () => { + const ids = collectToolCallIds([ + { id: "a", role: "user", content: "hi" }, + assistantMsg("m1", ["call_0", "toolu_x"]), + toolResultMsg("call_0"), + { id: "m2", role: "assistant", content: [{ type: "text", text: "no calls" }] }, + ]); + assert.deepEqual(ids, ["call_0", "toolu_x", "call_0"]); + }); +}); diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index 85bd375..3777373 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -1468,3 +1468,89 @@ describe("composer steering: POST /api/chats/[id]/steer", () => { assert.equal(steerMsg.content, "also cover Venus"); }); }); + +// ─── THE LACAN INCIDENT: per-response tool-call ids (owner fix 2026-10-04) ── +// DeepSeek (via OpenRouter) returns tool-call ids that reset every response — +// `call_0` for every turn's single call. Before the uniquifier, the duplicate +// ids corrupted the fold (the quiz answer overwrote the ask card, the ask +// question vanished), answer routing, and the shuffle seeds. Now the wire +// boundary remints colliding ids ONCE — the client, the record, the pending +// gate, and the next turn's provider context all see unique ids. +describe("duplicate provider tool-call ids (the Lacan fix)", () => { + it("every `call_0` turn keeps its identity: ask answered as ask, quiz as quiz, ids unique in the record", async () => { + // the live shape (the real Lacan session): an ask with NO options + const ASK = { question: "What do you want from Lacan?" }; + const QUIZ2 = { ...QUIZ_INPUT, type: "check" }; + // BOTH turns name their single call `call_0` — the provider's shape. + const { engine } = scriptedTeacher(() => [ + (context) => { + void context; + return fauxAssistantMessage( + [fauxText("First — a question about your goal."), fauxToolCall("ask_user_question", ASK, { id: "call_0" })], + { stopReason: "toolUse" }, + ); + }, + (context) => { + void context; + return fauxAssistantMessage( + [fauxText("The unconscious, then."), fauxToolCall("quiz", QUIZ2, { id: "call_0" })], + { stopReason: "toolUse" }, + ); + }, + () => fauxAssistantMessage([fauxText("Right — and that is where Lacan begins.")]), + ]); + + const chat = chatStore.createChat("Lacan: duplicate call_0 turns"); + // Turn 1: the ask question. + await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me Lacan" }), agentFactory(engine))); + let saved = chatStore.getChat(chat.id)!; + let pending = danglingQuestionOf(saved.messages)!; + assert.equal(pending.tool, "ask_user_question"); + // The client answers with the id IT SAW — the persisted one. + const askId = pending.toolCallId; + await drain( + await handleChatRequest( + post(chat.id, { + type: "answer", + toolCallId: askId, + answer: { kind: "ask", questionText: ASK.question, dontKnow: false, answers: [], text: "The theory core", answeredAt: new Date().toISOString() }, + }), + agentFactory(engine), + ), + ); + + // Turn 2: the quiz — ALSO `call_0` from the provider. + saved = chatStore.getChat(chat.id)!; + pending = danglingQuestionOf(saved.messages)!; + assert.equal(pending.tool, "quiz"); + const quizId = pending.toolCallId; + assert.notEqual(quizId, askId, "the uniquifier reminted the colliding id at the wire boundary"); + assert.match(quizId, /^call_0~\d+$/, "mint scheme: orig~n"); + await drain( + await handleChatRequest(post(chat.id, { type: "answer", toolCallId: quizId, answer: gradedAnswer(QUIZ2, ["Mercury"]) }), agentFactory(engine)), + ); + + saved = chatStore.getChat(chat.id)!; + // The invariant: CALL ids are unique among themselves (a call and its + // own result share one id by design — that pair is the point). + const callIds = saved.messages.flatMap( + (m) => + m.role === "assistant" + ? (m.content ?? []).filter((c) => c.type === "toolCall").map((c) => c.id) + : [], + ); + assert.equal(new Set(callIds).size, callIds.length, "no duplicate tool-call ids persist"); + assert.deepEqual(callIds, ["call_0", "call_0~2"], "the provider's two call_0s became distinct ids"); + + // The fold is the Lacan heal: the ask card carries the ASK answer, + // the quiz card the QUIZ answer — no overwrite, order preserved. + const view = projectView(saved.messages); + const parts = view.flatMap((v) => v.parts).filter((p) => p.type.startsWith("tool-")); + const askPart = parts.find((p) => (p as { toolCallId?: string }).toolCallId === askId) as { state?: string; output?: { kind?: string } }; + const quizPart = parts.find((p) => (p as { toolCallId?: string }).toolCallId === quizId) as { state?: string; output?: { kind?: string } }; + assert.equal(askPart?.state, "output-available"); + assert.equal(askPart?.output?.kind, "ask", "the ask card keeps its own answer"); + assert.equal(quizPart?.state, "output-available"); + assert.equal(quizPart?.output?.kind, "quiz", "the quiz card got ITS answer (the pre-fix bug: it landed on the ask card)"); + }); +});