Skip to content

fix(session): synthesize toolCallId when provider streams an empty one - #53687

Open
ggbdpq wants to merge 1 commit into
anomalyco:devfrom
ggbdpq:fix/empty-tool-call-id
Open

ggbdpq wants to merge 1 commit into
anomalyco:devfrom
ggbdpq:fix/empty-tool-call-id

Conversation

@ggbdpq

@ggbdpq ggbdpq commented Oct 7, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #53499

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

When a provider streams a tool call with an empty id (observed on mimo-v2.6-flash: every tool-call-delta arrived with id: ""), the invalid-tool repair path passed the call through verbatim, so the part was persisted with callID: "". Every later request replayed that part and the provider rejected the body with tool messages must include a non-empty string tool_call_id — retrying, continuing, or switching models all failed identically, and there was no UI path to remove the part, so the session could not self-heal.

The repair callback is extracted into a makeToolCallRepair helper (same behavior otherwise) and now falls back to a synthesized call_<ulid> id when the incoming call has an empty one, matching how session code already synthesizes ids in the proactive task/shell paths. Existing non-empty ids are untouched.

How did you verify your code works?

Added test/session/llm-repair.test.ts: an empty-id call comes back with toolName: "invalid" and a synthesized call_… id; a call that already has an id keeps it (and the lowercase-name repair branch still applies).

Full packages/opencode/test/session/ suite: 428 tests, the only 2 failures are the pre-existing xai image tests in message-v2.test.ts — I confirmed they fail identically on a clean checkout of dev without this change. tsgo --noEmit is clean.

Scope note: this fixes the repair path, which is the one the issue hit (unavailable tool → invalid). A provider streaming an empty id on a valid tool name would still slip through the normal path; I did not touch that since I could not reproduce it, and widening the change would need a streaming fixture that doesn't exist yet.

Screenshots / recordings

Not a UI change.

Checklist

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

experimental_repairToolCall passed the model's tool call through
verbatim, so a provider that streams a tool call with an empty id
observed on mimo-v2.6-flash got persisted as a part with callID: "".
From then on every request replayed that part and the provider rejected
it with "tool messages must include a non-empty string tool_call_id" -
retrying, continuing, or switching models all failed identically, and
there was no UI path to remove the part, so the session could not
self-heal.

The repair callback is extracted into makeToolCallRepair and now falls
back to a synthesized call_<ulid> id when the incoming tool call has an
empty one. Existing ids are untouched.

Closes anomalyco#53499

Generated-by: GLM-5.3-Flash (ZCode)
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid-tool fallback persists callID:"" for empty-id tool calls → every later request 400s, session permanently bricked

1 participant