From 41e85f0e0258546f124ca533ce6c1af512415455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Podsiad=C5=82y?= Date: Tue, 9 Jun 2026 14:47:23 +0200 Subject: [PATCH] fix(opencode): match MCP tool calls case- and hyphen-insensitively at dispatch --- packages/opencode/src/session/llm.ts | 16 +++++++++--- packages/opencode/test/session/llm.test.ts | 29 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index cf284ce1ae6e..f55d132a7a92 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -279,11 +279,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 { @@ -386,4 +386,14 @@ 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 * as LLM from "./llm" 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]