fix(kiro): preserve underscores in tool names and restore sanitized names in responses

Do not collapse consecutive underscores in uniqueName so mcp__server__tool is sent intact to Kiro, attach reverse map on request translation, and restore client tool names in responses.
This commit is contained in:
Qisthi Ramadhani
2026-09-17 18:13:14 +07:00
parent 82b1bca42a
commit c49efdf528
4 changed files with 118 additions and 7 deletions

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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", () => {

View File

@@ -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");
});
});