diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index adacfc431549..2b86fea241a7 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -294,11 +294,11 @@ 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]) { + const repaired = repairToolName(prepared.tools, failed.toolCall.toolName) + if (repaired) { return { ...failed.toolCall, - toolName: lower, + toolName: repaired, } } return { @@ -401,6 +401,16 @@ export const defaultLayer = Layer.suspend(() => export const hasToolCalls = LLMRequestPrep.hasToolCalls +// Match case/hyphen variants back to the registered key; ambiguous collisions resolve to undefined. +const normalizeToolName = (name: string) => name.toLowerCase().replaceAll("-", "_") + +export function repairToolName(tools: Record, name: string) { + if (tools[name]) return name + const target = normalizeToolName(name) + const matches = Object.keys(tools).filter((key) => normalizeToolName(key) === target) + return matches.length === 1 ? matches[0] : undefined +} + export const node = LayerNode.make(layer, [ Auth.node, Config.node, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 0c5dadaf17d6..973897025377 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -173,6 +173,35 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.repairToolName", () => { + const stub = tool({ + description: "", + inputSchema: z.object({}), + execute: async () => "", + }) + const tools = (...names: string[]) => Object.fromEntries(names.map((name) => [name, stub])) + + test("matches a lowercased capitalized-prefix MCP tool (#31506)", () => { + expect(LLM.repairToolName(tools("Tool_get_prop"), "tool_get_prop")).toBe("Tool_get_prop") + }) + + test("matches an all-underscore variant of a hyphenated MCP tool (#27396)", () => { + expect(LLM.repairToolName(tools("server_get-by-id"), "server_get_by_id")).toBe("server_get-by-id") + }) + + test("returns the exact name when it already matches", () => { + expect(LLM.repairToolName(tools("Tool_get_prop"), "Tool_get_prop")).toBe("Tool_get_prop") + }) + + test("returns undefined for an unknown tool", () => { + expect(LLM.repairToolName(tools("Tool_get_prop"), "totally_unknown")).toBeUndefined() + }) + + test("returns undefined when the normalized name is ambiguous", () => { + expect(LLM.repairToolName(tools("Tool_get_prop", "tool_get_prop"), "TOOL_GET_PROP")).toBeUndefined() + }) +}) + describe("session.llm.ai-sdk adapter", () => { type AISDKAdapterEvent = Parameters[1]