diff --git a/open-sse/translator/concerns/kiroConversation.js b/open-sse/translator/concerns/kiroConversation.js index 5ac1485b..b19e9ba6 100644 --- a/open-sse/translator/concerns/kiroConversation.js +++ b/open-sse/translator/concerns/kiroConversation.js @@ -48,7 +48,6 @@ function uniqueName(rawName, index, usedNames) { const cleaned = String(rawName || "") .trim() .replace(TOOL_NAME_PATTERN, "_") - .replace(/_+/g, "_") .replace(/^_+|_+$/g, ""); const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH); let candidate = base; diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js index 9118b9cb..d9fc0aab 100644 --- a/open-sse/translator/response/kiro-to-claude.js +++ b/open-sse/translator/response/kiro-to-claude.js @@ -48,9 +48,9 @@ function convertFinishReason(reason) { */ // Kiro only accepts sanitized tool names; the request translator leaves the // reverse map on the stream state so calls come back under the client's names. -function restoreToolName(state, name) { +function restoreToolName(stateOrData, name) { const raw = name || ""; - const map = state?.toolNameMap; + const map = stateOrData?.toolNameMap || stateOrData?._toolNameMap; return map && typeof map.get === "function" && map.has(raw) ? map.get(raw) : raw; } @@ -254,7 +254,7 @@ export function kiroToClaudeNonStreaming(data) { content.push({ type: "tool_use", id: tc.id || `toolu_${Date.now()}`, - name: restoreToolName(state, tc.function?.name), + name: restoreToolName(data, tc.function?.name), input, }); } diff --git a/tests/translator/agent-client-fixes.test.js b/tests/translator/agent-client-fixes.test.js index 3f4e7cd7..6004db80 100644 --- a/tests/translator/agent-client-fixes.test.js +++ b/tests/translator/agent-client-fixes.test.js @@ -79,12 +79,14 @@ describe("tool-result images reach OpenAI-format upstreams", () => { describe("Kiro tool names round-trip", () => { it("returns the sanitized→original map on the translated body", () => { - const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", screenshotTurn(), true, null, "kiro"); + const body = screenshotTurn(); + body.tools = [{ name: "mcp.browser.computer", description: "browser", input_schema: { type: "object", properties: {} } }]; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro"); expect(out._toolNameMap).toBeInstanceOf(Map); - expect(out._toolNameMap.get("mcp_browser_computer")).toBe("mcp__browser__computer"); + expect(out._toolNameMap.get("mcp_browser_computer")).toBe("mcp.browser.computer"); const wire = JSON.parse(JSON.stringify(out.conversationState)); expect(JSON.stringify(wire)).toContain("mcp_browser_computer"); - expect(JSON.stringify(wire)).not.toContain("mcp__browser__computer"); + expect(JSON.stringify(wire)).not.toContain("mcp.browser.computer"); }); it("omits the map when no name changed", () => { diff --git a/tests/translator/kiro-tool-name-roundtrip.test.js b/tests/translator/kiro-tool-name-roundtrip.test.js new file mode 100644 index 00000000..a8f151ad --- /dev/null +++ b/tests/translator/kiro-tool-name-roundtrip.test.js @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { normalizeKiroToolSpecs } from "../../open-sse/translator/concerns/kiroConversation.js"; +import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js"; +import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js"; +import { kiroToOpenAIResponse } from "../../open-sse/translator/response/kiro-to-openai.js"; +import { kiroToClaudeResponse, kiroToClaudeNonStreaming } from "../../open-sse/translator/response/kiro-to-claude.js"; + +describe("Kiro tool name normalization and roundtrip", () => { + it("preserves consecutive underscores like mcp__gitea__search_repos without collapsing", () => { + const { specs, nameMap } = normalizeKiroToolSpecs([ + { name: "mcp__gitea__search_repos", description: "Search Gitea" }, + ]); + expect(specs).toHaveLength(1); + expect(specs[0].toolSpecification.name).toBe("mcp__gitea__search_repos"); + expect(nameMap.get("mcp__gitea__search_repos")).toBe("mcp__gitea__search_repos"); + }); + + it("builds _toolNameMap for illegal characters and deduplicates colliding names", () => { + const tools = [ + { name: "my.tool/search", description: "tool 1" }, + { name: "my_tool_search", description: "tool 2" }, + ]; + const openaiPayload = openaiToKiroRequest("claude-sonnet-4.6", { + tools: tools.map((t) => ({ type: "function", function: t })), + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(openaiPayload._toolNameMap).toBeInstanceOf(Map); + // my.tool/search cleaned to my_tool_search. Since my_tool_search comes next, it becomes my_tool_search_2 + expect(openaiPayload._toolNameMap.get("my_tool_search")).toBe("my.tool/search"); + + const claudePayload = claudeToKiroRequest("claude-sonnet-4.6", { + tools, + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(claudePayload._toolNameMap).toBeInstanceOf(Map); + expect(claudePayload._toolNameMap.get("my_tool_search")).toBe("my.tool/search"); + }); + + it("does not attach _toolNameMap when all tool names are legal and unchanged", () => { + const tools = [ + { name: "mcp__gitea__search_repos", description: "Search Gitea" }, + { name: "bash_exec", description: "Run bash" }, + ]; + const payload = openaiToKiroRequest("claude-sonnet-4.6", { + tools: tools.map((t) => ({ type: "function", function: t })), + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(payload._toolNameMap).toBeUndefined(); + }); + + it("restores original tool name in kiroToOpenAIResponse when state.toolNameMap is present", () => { + const state = { + toolNameMap: new Map([["my_tool_search", "my.tool/search"]]), + }; + const event = { + toolUseEvent: { + toolUseId: "call_123", + name: "my_tool_search", + input: { q: "test" }, + }, + }; + const chunk = kiroToOpenAIResponse(event, state); + expect(chunk).not.toBeNull(); + expect(chunk.choices[0].delta.tool_calls[0].function.name).toBe("my.tool/search"); + }); + + it("restores original tool name in kiroToClaudeResponse streaming when state.toolNameMap is present", () => { + const state = { + toolNameMap: new Map([["my_tool_search", "my.tool/search"]]), + toolCalls: new Map(), + nextBlockIndex: 0, + }; + const chunk = { + id: "chatcmpl-1", + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: "call_123", + type: "function", + function: { name: "my_tool_search", arguments: "" }, + }], + }, + }], + }; + const events = kiroToClaudeResponse(chunk, state); + const startEvent = events.find((e) => e.type === "content_block_start"); + expect(startEvent).toBeDefined(); + expect(startEvent.content_block.name).toBe("my.tool/search"); + }); + + it("restores original tool name in kiroToClaudeNonStreaming when toolNameMap is present", () => { + const data = { + choices: [{ + message: { + tool_calls: [{ + id: "call_123", + function: { name: "my_tool_search", arguments: "{}" }, + }], + }, + }], + toolNameMap: new Map([["my_tool_search", "my.tool/search"]]), + }; + const result = kiroToClaudeNonStreaming(data); + expect(result.content[0].name).toBe("my.tool/search"); + }); +});