tool-call id uniquifier: the Lacan fix (owner-approved 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. Everything keys on
toolCallId (the fold's call↔result join, the pending gate, answer
routing, the option-shuffle seed), so the Lacan session corrupted: the
quiz answer overwrote the ask card, the ask question vanished, and the
order scrambled. Record itself was always intact.

- src/server/pi/call-ids.ts (new): ToolCallIdUniquifier at the wire
  boundary — colliding ids remint once (orig~2, ~3…), the same mints at
  settlement; tables key by message OBJECT IDENTITY (streaming messages
  carry no ids; the message_end object is reference-identical to the
  state object). History never rewrites; providers see paired ids, which
  is protocol-legal (pi synthesizes ids for providers that send none).
- ui-stream: runPiTurn remaps events + settles through the uniquifier
  (spec.usedToolCallIds, seeded by chat-handler from the record).
- view.ts: findToolPart scans newest-first — pre-fix duplicate-id
  records (the existing Lacan session) heal on next render.
- tests: 9 unit pins (call-ids.test.ts) + the two-call_0 e2e (chat-flow):
  ask answered as ask, quiz as quiz, ids unique in the record.
This commit is contained in:
amosblomqvist
2026-10-04 01:19:54 +03:00
parent 360d564a6c
commit 17a497a87c
6 changed files with 477 additions and 10 deletions
+142
View File
@@ -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"]);
});
});
+86
View File
@@ -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)");
});
});