Skip to content

[Bug]: Reused OpenAI tool-call IDs stop conversations with history-update errors (0.6.12; follow-up #490) #523

Open

Description

@knobusawa

Product or interface

CLI - interactive TUI (observed); CLI - headless (independent reproduction). Source fix and tests use pnpm 9.12.0.

Summary

Follow-up to #490, reported from the same environment, but with a different proven failure mode. #490 concerns compaction lineage and failure atomicity. This report concerns reused OpenAI-compatible tool-call IDs: a later model response is rejected by canonical history even though the prior call/result pair has already completed. The UI renders the same generic error for both problems, which made the recurrence look like the old bug.

This is reproducible with the currently published npm package @minimax-ai/code 0.6.12, checked on 2026-10-22, and with the previously installed 0.6.11. No real API key, managed login, actual model, real conversation, or changes to the user's existing permission settings are required. The supplied local HTTP fixture returns the same synthetic tool-call ID on three successive responses and uses only a temporary read-only file tool scenario.

Observed incident and scope

The interactive incident was in a customized source build based on local commit 0ab65f6, reporting version 0.6.5, on Windows 11 Enterprise 10.0.22631 x64 / Node.js 24.19.0. It was NOT an unmodified current npm build; that is why I separately reproduced the failure against official 0.6.11 and current 0.6.12 artifacts.

Redacted local diagnostics show six failed turns on 2026-10-22 with the underlying reason:

Canonical history sequence is invalid at record <n>: duplicate tool call identity <call-id>

The most recent instances occur while polling task_output through an OpenAI-compatible BYOK GLM endpoint, both in a user turn and in background-task delivery turns. The repeated IDs already belong to earlier completed tool calls in the same active history. A strict read-only inspection of the active history succeeds: in these instances the new append is refused, rather than an already-persisted conversation requiring deletion. No claim is made about whether the upstream model or a gateway generated the reused IDs.

Independent reproduction

Apply the test/byok.test.mjs addition below, then point MCODE_TEST_CLI at the official package's cli.js and run:

node --test --test-name-pattern='BYOK reused tool IDs' test/byok.test.mjs

The fixture isolates data/home directories and denies external network access. Request 1 returns read(id=reused-call), request 2 carries its completed tool result, and the fixture returns another read(id=reused-call). The expected result is three separately identified reads, correctly paired tool results in subsequent requests and persisted JSONL, and a final answer after four streamed model requests.

Actual on unpatched source, official 0.6.11, and official 0.6.12: CLI exit 4, with:

mcode exec failed: The run failed: Conversation history could not be safely updated. Please retry.. Retry after the connection recovers.

This is a deterministic local identity collision, not a connection failure. Retrying the same polling operation can encounter the same collision again.

Cause and proposed boundary

The OpenAI streaming adapter on public main da22f11 copies raw provider tool-call IDs directly into agent messages and only tracks them within one response. Its incoming-call implementation is identical between that public revision and my unpatched source baseline. Canonical history, correctly for its downstream consumers, requires unique tool-call identities across the active transcript. The adapter does not reconcile this contract when a provider reuses an ID.

A second synthetic case exposes a related adapter problem: two distinct parallel indexes with the same raw ID are merged into one block by the ID fallback, potentially mixing arguments. The proposed fix treats explicit stream indexes as authoritative.

The attached narrow patch reserves IDs from the input context, preserves noncolliding IDs, and allocates a free bounded ID for a colliding incoming call before it is exposed to the agent. Repeated deltas for the same stream index keep their identity. Execution events, tool results, subsequent outbound history, and durable messages consequently use the same assigned ID. Existing conversation content and identities are not rewritten, completed calls are not silently deduplicated, and canonical validation remains enabled.

The patch fails closed for ambiguous ID-only continuations after a parallel collision, and for reused IDs associated with encrypted reasoning whose identity cannot safely be rebound. It does not claim generalized signed-reasoning compatibility or implement cross-process locking. The shared provider adapter is used below the TUI, exec and ACP paths; the end-to-end acceptance below is headless exec, not a manual ACP or TUI test.

Verification

Before the runtime patch, the initial focused transport tests produced 4 failed / 1 passed, covering history collision, parallel collision, a late-arriving ID, an already occupied suffix and the noncollision control. The built CLI regression failed against both official npm versions as described above.

With the patch, four related source suites pass 377 tests, including eight new transport cases plus compaction, retry, agent execution and strict history tests. The focused built-CLI run passes the new repeated-ID regression and both existing interrupted-stream reconnect tests (3/3). The deployed build's repeated-ID test also passes. TypeScript checking, standalone build, source inventory, generated-path validation, build boundary and git diff --check pass.

The full BYOK gate was also run: 4 passed, 1 failed, and 2 Windows-specific skips. The pre-existing image-input scenario fails with CLI exit 7 / step limit exceeded. Running that same scenario against the saved unpatched source build reproduces the same failure; it is not reported as a passing gate or attributed to this patch. The complete source-export/full verification profile was not run on this dirty local checkout.

Linux/macOS, live-provider acceptance, manual interactive/ACP acceptance, and the full performance profile have not been run. This is not a claim that the entire verification pipeline is green. The local working tree contains unrelated pre-existing TUI work; it is preserved and deliberately excluded from the patch below. No real session content, credentials, full logs or private project data are included.

Maintainer request

Please consider incoming tool identity normalization at the provider/runtime boundary, plus distinct actionable diagnostics for history identity collisions versus compaction lineage failures. A generic network-retry recommendation hides this deterministic failure. This is an issue-first proposal under the collaborator-only contribution policy, not an unsolicited PR; the previous report remains #490 and should not be treated as fixed by this patch.

Candidate runtime and synthetic regression diff
diff --git a/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts
index 14d0522..3761a48 100644
--- a/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts
+++ b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts
@@ -11,6 +11,64 @@ import { createCheckpointSession } from "../../src/service/turn-system/compactio
 const summary =
   "The synthetic file was read. Continue with the pending user request.";
 
+describe('reused OpenAI tool identities', () => {
+  it.each(['history', 'parallel', 'late-id', 'repeated-delta', 'unchanged', 'reserved-suffix', 'encrypted', 'ambiguous'])('%s keeps distinct calls and stable streamed identities', async (scenario) => {
+    const model: Model<'openai-completions'> = {
+      id: 'fixture-model', name: 'fixture', api: 'openai-completions',
+      provider: 'custom_provider:fixture', baseUrl: 'https://example.invalid/v1',
+      reasoning: false, input: ['text'], contextWindow: 32768, maxTokens: 4096,
+      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+    };
+    const parallel = scenario === 'parallel' || scenario === 'ambiguous';
+    const messages = parallel || scenario === 'unchanged' ? [] : history(model);
+    if (scenario === 'reserved-suffix') {
+      const previous = structuredClone(history(model));
+      const assistant = previous[1];
+      const result = previous[2];
+      if (assistant.role === 'assistant' && assistant.content[0].type === 'toolCall') assistant.content[0].id = 'read-1_mcode_2';
+      if (result.role === 'toolResult') result.toolCallId = 'read-1_mcode_2';
+      messages.push(...previous);
+    }
+    const original = structuredClone(messages);
+    const deltas: Record<string, unknown>[] = scenario === 'late-id' || scenario === 'repeated-delta'
+      ? [{ tool_calls: [{ index: 0, ...(scenario === 'repeated-delta' ? { id: 'read-1' } : {}), function: { name: 'read', arguments: '{"path":' } }] },
+         { tool_calls: [{ index: 0, id: 'read-1', function: { arguments: '"next.txt"}' } }] }]
+      : [{ tool_calls: [{ index: 0, id: 'read-1', function: { name: 'read', arguments: '{"path":"next.txt"}' } },
+          ...(parallel ? [{ index: 1, id: 'read-1', function: { name: 'read', arguments: '{"path":"other.txt"}' } }] : [])] }];
+    if (scenario === 'encrypted') deltas.push({ reasoning_details: [{ type: 'reasoning.encrypted', id: 'read-1', data: 'opaque-synthetic-signature' }] });
+    if (scenario === 'ambiguous') deltas.push({ tool_calls: [{ id: 'read-1', function: { arguments: ' ' } }] });
+    const wire = [...deltas.map(delta => ({ choices: [{ index: 0, delta, finish_reason: null }] })),
+      { choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }]
+      .map(chunk => `data: ${JSON.stringify({ id: 'synthetic-response', ...chunk })}\n\n`).join('') + 'data: [DONE]\n\n';
+    const response = streamSimple(model, { messages }, {
+      apiKey: 'synthetic', fetch: async () => new Response(wire, { headers: { 'content-type': 'text/event-stream' } }),
+    });
+    const ends: string[] = [];
+    for await (const event of response) {
+      if (event.type === 'toolcall_end') ends.push(event.toolCall.id);
+    }
+    const final = await response.result();
+    if (scenario === 'encrypted' || scenario === 'ambiguous') {
+      expect(final.stopReason).toBe('error');
+      expect(final.errorMessage).toContain(scenario === 'encrypted' ? 'cannot safely rebind' : 'ambiguous');
+      expect(messages).toEqual(original);
+      return;
+    }
+    expect(final.stopReason).toBe('toolUse');
+    const calls = final.content.filter(block => block.type === 'toolCall');
+    expect(calls).toHaveLength(scenario === 'parallel' ? 2 : 1);
+    const ids = calls.map(call => call.id);
+    expect(new Set(ids).size).toBe(ids.length);
+    expect(ends).toEqual(ids);
+    expect(calls[0].arguments).toEqual({ path: 'next.txt' });
+    if (scenario === 'parallel') expect(calls[1].arguments).toEqual({ path: 'other.txt' });
+    if (messages.length) expect(ids).not.toContain('read-1');
+    else expect(ids[0]).toBe('read-1');
+    if (scenario === 'reserved-suffix') expect(ids).not.toContain('read-1_mcode_2');
+    expect(messages).toEqual(original);
+  });
+});
+
 function history(model: Model<"openai-completions">): Context["messages"] {
   return [
     { role: "user", content: "Read the synthetic file.", timestamp: 1 },
diff --git a/test/byok.test.mjs b/test/byok.test.mjs
index 7d429ae..998d315 100644
--- a/test/byok.test.mjs
+++ b/test/byok.test.mjs
@@ -1156,3 +1156,107 @@ function cancellationTest(cancellation) {
     );
   };
 }
+
+test("BYOK reused tool IDs survive repeated execution and canonical persistence", { timeout: 60000 }, async (t) => {
+  const fixtureDir = mkdtempSync(path.join(tmpdir(), "minimax-code-tool-id-"));
+  const dataDir = path.join(fixtureDir, "data");
+  const workspaceDir = path.join(fixtureDir, "workspace");
+  const homeDir = path.join(fixtureDir, "home");
+  for (const directory of [dataDir, workspaceDir, homeDir]) mkdirSync(directory);
+  const readPath = path.join(workspaceDir, "completed-tool.txt");
+  writeFileSync(readPath, "COMPLETED_TOOL_RESULT");
+  const networkAudit = path.join(fixtureDir, "network-audit.log");
+  let streamRequests = 0;
+  const server = createServer(async (req, res) => {
+    let raw = "";
+    for await (const chunk of req) raw += chunk;
+    const body = JSON.parse(raw);
+    if (req.url?.endsWith("/responses/input_tokens")) {
+      res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ input_tokens: 1 }));
+      return;
+    }
+    if (!body.stream) {
+      res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
+        id: "fixture", object: "chat.completion", model: "fixture",
+        choices: [{ index: 0, message: { role: "assistant", content: "synthetic title" }, finish_reason: "stop" }],
+      }));
+      return;
+    }
+    streamRequests += 1;
+    res.writeHead(200, { "content-type": "text/event-stream" });
+    const send = (delta, finishReason = null) => res.write(`data: ${JSON.stringify({
+      id: "fixture", object: "chat.completion.chunk", created: 1, model: "fixture",
+      choices: [{ index: 0, delta, finish_reason: finishReason }],
+    })}\n\n`);
+    const results = body.messages.filter(message => message.role === "tool");
+    assert.equal(results.length, streamRequests - 1);
+    const calls = body.messages.flatMap(message => message.tool_calls ?? []);
+    assert.equal(new Set(calls.map(call => call.id)).size, calls.length);
+    assert.deepEqual(results.map(result => result.tool_call_id), calls.map(call => call.id));
+    for (const result of results) assert.match(JSON.stringify(result), /COMPLETED_TOOL_RESULT/u);
+    if (streamRequests <= 3) {
+      send({ role: "assistant", tool_calls: [{
+        index: 0, id: "reused-call", type: "function",
+        function: { name: "read", arguments: JSON.stringify({ path: readPath }) },
+      }] });
+      send({}, "tool_calls");
+      res.end("data: [DONE]\n\n");
+      return;
+    }
+    send({ role: "assistant", content: "RECONNECTED_OK" });
+    send({}, "stop");
+    res.end("data: [DONE]\n\n");
+  });
+  let child;
+  t.after(async () => {
+    if (child && child.exitCode === null) child.kill("SIGKILL");
+    server.closeAllConnections();
+    await new Promise(resolve => server.close(resolve));
+    rmSync(fixtureDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+  });
+  await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
+  const origin = `http://127.0.0.1:${server.address().port}`;
+  writeFileSync(path.join(dataDir, "config.yaml"), stringifyYaml({
+    defaultModel: "custom_provider:fixture/fixture",
+    custom_provider: {
+      fixture: {
+        name: "fixture", kind: "custom", enabled: true, api: "openai-completions",
+        options: { apiKey: "synthetic-key", baseURL: `${origin}/v1`, authMode: "api-key" },
+        models: { fixture: { limit: { context: 32768, output: 4096 } } },
+      },
+    },
+  }));
+  child = spawn(process.execPath, [
+    cli, "exec", "Reply RECONNECTED_OK only.", "--mode", "standard",
+    "--permission", "off", "--cwd", workspaceDir, "--timeout", "45s", "--max-steps", "5",
+  ], {
+    cwd: workspaceDir,
+    env: {
+      ...withoutProxyEnvironment(process.env), HOME: homeDir,
+      XDG_CONFIG_HOME: path.join(homeDir, "config"), XDG_DATA_HOME: path.join(homeDir, "data"),
+      MINIMAX_DATA_DIR: dataDir, MAVIS_DATA_DIR: dataDir,
+      MCODE_TEST_ALLOWED_ORIGIN: origin, MCODE_TEST_NETWORK_AUDIT: networkAudit,
+      MCODE_TEST_MANAGED_OFFLINE: "1",
+      NODE_OPTIONS: `--import=${new URL("./network-deny.mjs", import.meta.url).href}`,
+    },
+    stdio: ["ignore", "pipe", "pipe"],
+  });
+  let stdout = "";
+  let stderr = "";
+  child.stdout.on("data", chunk => { stdout += chunk; });
+  child.stderr.on("data", chunk => { stderr += chunk; });
+  const [code] = await once(child, "close");
+  assert.equal(code, 0, `${stdout}\n${stderr}`);
+  assert.equal(streamRequests, 4, "Three intentional reads must complete, with no history failure or retry");
+  assert.equal(stdout.split("RECONNECTED_OK").length - 1, 1, "Only the successful answer is published");
+  assert.doesNotMatch(stdout, /DISCARD_PARTIAL_RESPONSE/u);
+  assert.equal(existsSync(networkAudit), false, "No external requests are allowed");
+  const records = readdirSync(path.join(dataDir, "v2", "sessions"), { recursive: true })
+    .filter(name => name.endsWith("messages.jsonl"))
+    .flatMap(name => readFileSync(path.join(dataDir, "v2", "sessions", name), "utf8").trim().split("\n").filter(Boolean).map(JSON.parse));
+  const calls = records.flatMap(record => record.message?.role === "assistant" ? record.message.content.filter(block => block.type === "toolCall") : []);
+  const results = records.filter(record => record.message?.role === "toolResult");
+  assert.equal(calls.length, 3);
+  assert.equal(new Set(calls.map(call => call.id)).size, 3);
+  assert.deepEqual(results.map(record => record.message.toolCallId), calls.map(call => call.id));
+});
diff --git a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts
index 2f52683..97fb28f 100644
--- a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts
+++ b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts
@@ -153,6 +153,26 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
 			let hasFinishReason = false;
 			const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>();
 			const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
+			const ambiguousToolCallIds = new Set<string>();
+			// Chat-compatible endpoints may reuse a call ID in later responses.
+			// Assign the incoming call a free identity before any event reaches the
+			// agent, so execution, tool results and durable history all agree.
+			const usedToolCallIds = new Set<string>();
+			for (const message of context.messages) {
+				if (message.role === "assistant") {
+					for (const block of message.content) {
+						if (block.type === "toolCall") usedToolCallIds.add(block.id);
+					}
+				} else if (message.role === "toolResult") usedToolCallIds.add(message.toolCallId);
+			}
+			const allocateToolCallId = (rawId: string): string => {
+				if (!rawId) return rawId;
+				let id = rawId;
+				let suffix = 2;
+				while (usedToolCallIds.has(id)) id = `${rawId.slice(0, 48)}_mcode_${suffix++}`;
+				usedToolCallIds.add(id);
+				return id;
+			};
 			const blocks = output.content as StreamingBlock[];
 			const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block);
 			const finishBlock = (block: StreamingBlock) => {
@@ -210,14 +230,20 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
 			};
 			const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => {
 				const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined;
+				if (streamIndex === undefined && toolCall.id && ambiguousToolCallIds.has(toolCall.id)) {
+					throw new Error("Provider omitted the index for an ambiguous reused tool call ID");
+				}
 				let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined;
 				if (!block && toolCall.id) {
-					block = toolCallBlocksById.get(toolCall.id);
+					const byId = toolCallBlocksById.get(toolCall.id);
+					// An explicit index identifies a separate parallel call even when
+					// the provider mistakenly gives both calls the same raw ID.
+					if (streamIndex === undefined || byId?.streamIndex === undefined) block = byId;
 				}
 				if (!block) {
 					block = {
 						type: "toolCall",
-						id: toolCall.id || "",
+						id: allocateToolCallId(toolCall.id || ""),
 						name: toolCall.function?.name || "",
 						arguments: {},
 						partialArgs: "",
@@ -227,6 +253,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
 						toolCallBlocksByIndex.set(streamIndex, block);
 					}
 					if (toolCall.id) {
+						if (toolCallBlocksById.has(toolCall.id)) ambiguousToolCallIds.add(toolCall.id);
 						toolCallBlocksById.set(toolCall.id, block);
 					}
 					blocks.push(block);
@@ -331,7 +358,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
 						for (const toolCall of choice.delta.tool_calls) {
 							const block = ensureToolCallBlock(toolCall);
 							if (!block.id && toolCall.id) {
-								block.id = toolCall.id;
+								block.id = allocateToolCallId(toolCall.id);
 								toolCallBlocksById.set(toolCall.id, block);
 							}
 							if (!block.name && toolCall.function?.name) {
@@ -357,10 +384,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
 					if (reasoningDetails && Array.isArray(reasoningDetails)) {
 						for (const detail of reasoningDetails) {
 							if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
-								const matchingToolCall = output.content.find(
-									(b) => b.type === "toolCall" && b.id === detail.id,
-								) as ToolCall | undefined;
+								const matchingToolCall = toolCallBlocksById.get(detail.id);
 								if (matchingToolCall) {
+									// Do not rewrite an opaque signature's bound identity.
+									if (matchingToolCall.id !== detail.id) {
+										throw new Error("Provider reused a tool call ID with encrypted reasoning; cannot safely rebind it");
+									}
 									matchingToolCall.thoughtSignature = JSON.stringify(detail);
 								}
 							}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions