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:
+9
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>> {
|
||||
const content = (message as { content?: unknown } | undefined)?.content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.filter(
|
||||
(b): b is { id: string } & Record<string, unknown> =>
|
||||
(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<string>();
|
||||
/** assistant message object → its (origId → uniqueId) table. Weak: the
|
||||
* agent's turn dies at settle; nothing here outlives it. */
|
||||
private readonly tables = new WeakMap<object, Map<string, string>>();
|
||||
/** 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<string, string> | null = null;
|
||||
|
||||
constructor(usedIds: Iterable<string>) {
|
||||
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<string, string> | 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user