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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
110
tests/translator/kiro-tool-name-roundtrip.test.js
Normal file
110
tests/translator/kiro-tool-name-roundtrip.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user