fix(opencode): stop a repeated tool call id from failing the session for good

Merged
PI
1mo ago
+164-4

Issue for this PR

A customer's chat on Replo (the NURA product page) returned only errors for 13 turns in a row on 2026-09-29. Every turn failed with Anthropic's 400 messages.291.content.1: tool_use ids must be unique. The same error has bricked 6 sessions in 6 projects since 2026-09-22.

Type of change

  • Bug fix

What does this PR do?

Every affected session holds one assistant step with two tool parts sharing a call id: the real call (read, completed) and a phantom named unknown, with input {} and state "Tool execution aborted" (interrupted: true). The phantom was created about 0.6s after the real call finished.

How it happens: when a call completes, settleToolCall drops it from ctx.toolcalls, and the AI SDK adapter drops its name from toolNames. If the provider stream then sends another tool-input-delta or tool-input-end for that id, ensureToolCall finds nothing and creates a fresh pending part. Its name is unknown because those two events are the only path that falls back to "unknown". The step-finish cleanup then marks it aborted. From then on toModelMessages replays both parts as tool_use blocks with the same id, Anthropic rejects the request as non-retryable, and the session can't recover.

Two changes:

  • processor.ts: settleToolCall records each call it settles, and ensureToolCall ignores any later input or call event for that id. This also stops a repeated tool-call event from starting a finished tool again.
  • message-v2.ts: history replay sends each call id once per assistant message and keeps the first part, which is the real call because the phantom is always created after it. The scope is one message rather than the whole history because some providers reuse ids like call_0 across responses, and deduping across messages would drop their real calls. This change is also what repairs sessions that already contain a duplicate: their next turn goes through without editing any stored data.

All 6 production hits were tagged [Google], i.e. OpenRouter serving Anthropic through Vertex. I haven't confirmed which exact late event that route sends. The fix doesn't depend on it.

How did you verify your code works?

  • New processor test: a stream that sends tool-input-delta and tool-input-end after the call's result. Before the fix it recorded [call-1, unknown, error] next to the real part; now it records only [call-1, lookup, completed].
  • New toModelMessages test with the exact persisted shape from production (completed read plus an aborted unknown part with the same call id). Before the fix it emitted two tool-call blocks for call-1; now it emits one.
  • packages/opencode: bun typecheck is clean and bun test passes 3660. The one failure, test/cli/cmd/tui/attention.test.ts, is flaky under full-suite load: it passes 3/3 run alone and doesn't import either changed file.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

šŸ¤– Generated with Claude Code


Summary by cubic

Fixes sessions being permanently bricked by duplicated tool call ids, which Anthropic rejects on every subsequent turn. The processor now ignores late input and repeated call events for already-finished tool calls, and history replay sends each call id only once, which also repairs sessions that already contain a duplicate. Deduplication is scoped to a single assistant message so providers that reuse call ids across responses keep their real calls.

  • When a fallback model takes over a dead route, discarding the route's pending tool parts now forgets those call ids instead of settling them, so the fallback model can reuse the same id for its own real call.