From 9057f88c35154661cdae81c5c36aab3621b28e13 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 7 Oct 2026 13:27:54 +0800 Subject: [PATCH] fix(session): synthesize toolCallId when provider streams an empty one 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_ id when the incoming tool call has an empty one. Existing ids are untouched. Closes #53499 Generated-by: GLM-5.3-Flash (ZCode) --- packages/opencode/src/session/llm.ts | 46 +++++++++++-------- .../opencode/test/session/llm-repair.test.ts | 38 +++++++++++++++ 2 files changed, 66 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/session/llm-repair.test.ts diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20c..1283b5626b16 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -6,7 +6,8 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" -import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" +import { streamText, wrapLanguageModel, type ModelMessage, type Tool, type ToolCallRepairFunction, type ToolSet } from "ai" +import { ulid } from "ulid" import type { LLMEvent } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import type { LLMClientService } from "@opencode-ai/llm/route" @@ -32,6 +33,31 @@ import { LLMRequestPrep } from "./llm/request" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX +export function makeToolCallRepair(tools: TOOLS): ToolCallRepairFunction { + return async (failed) => { + const lower = failed.toolCall.toolName.toLowerCase() + // a provider that streams tool calls without an id would otherwise persist + // callID: "" and every later request replays it, which providers reject + const toolCallId = failed.toolCall.toolCallId || `call_${ulid()}` + if (lower !== failed.toolCall.toolName && tools[lower]) { + return { + ...failed.toolCall, + toolCallId, + toolName: lower, + } + } + return { + ...failed.toolCall, + toolCallId, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: failed.error.message, + }), + toolName: "invalid", + } + } +} + export type StreamInput = { user: SessionV1.User sessionID: string @@ -293,23 +319,7 @@ const live: Layer.Layer< }, // Copilot returns the authoritative billed amount only in provider-specific response fields. includeRawChunks: input.model.providerID.includes("github-copilot"), - async experimental_repairToolCall(failed) { - const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && prepared.tools[lower]) { - return { - ...failed.toolCall, - toolName: lower, - } - } - return { - ...failed.toolCall, - input: JSON.stringify({ - tool: failed.toolCall.toolName, - error: failed.error.message, - }), - toolName: "invalid", - } - }, + experimental_repairToolCall: makeToolCallRepair(prepared.tools), temperature: prepared.params.temperature, topP: prepared.params.topP, topK: prepared.params.topK, diff --git a/packages/opencode/test/session/llm-repair.test.ts b/packages/opencode/test/session/llm-repair.test.ts new file mode 100644 index 000000000000..052e76eb2234 --- /dev/null +++ b/packages/opencode/test/session/llm-repair.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test" +import { makeToolCallRepair } from "@/session/llm" + +const tools = { bash: {}, edit: {} } as unknown as Parameters[0] + +function repairInput(toolCall: Record, message: string) { + return { + system: undefined, + messages: [], + tools: tools as never, + inputSchema: () => ({}) as never, + toolCall: toolCall as never, + error: { message } as never, + } +} + +describe("session.llm.makeToolCallRepair", () => { + it("synthesizes a toolCallId when the model emitted an empty one", async () => { + const repair = makeToolCallRepair(tools) + const repaired = await repair( + repairInput({ toolCallId: "", toolName: "", input: "" }, "Model tried to call unavailable tool ''."), + ) + + expect(repaired).not.toBeNull() + expect(repaired!.toolName).toBe("invalid") + expect(repaired!.toolCallId.startsWith("call_")).toBe(true) + expect(repaired!.toolCallId.length).toBeGreaterThan(10) + }) + + it("keeps an existing non-empty toolCallId", async () => { + const repair = makeToolCallRepair(tools) + const repaired = await repair(repairInput({ toolCallId: "call_existing", toolName: "Bash", input: "{}" }, "No such tool capacity.")) + + expect(repaired).not.toBeNull() + expect(repaired!.toolCallId).toBe("call_existing") + expect(repaired!.toolName).toBe("bash") + }) +})