fix: prefix tool_choice.name when type is tool

Addresses review feedback: when tool_choice specifies a specific tool
(type: 'tool'), the name must also be prefixed to match the prefixed
tool names in the tools array.
This commit is contained in:
Hannes Rudolph 2026-01-11 11:47:21 -07:00
parent 6173606fe3
commit f578dfb4ef
2 changed files with 111 additions and 2 deletions

View file

@ -719,6 +719,92 @@ describe("Claude Code Streaming Client", () => {
expect(userMessage.content[0].tool_use_id).toBe("tool_123")
})
test("should prefix tool name in tool_choice when type is tool", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const tools = [
{
name: "read_file",
description: "Read a file",
input_schema: { type: "object" as const, properties: {} },
},
]
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Read a file" }],
tools,
toolChoice: { type: "tool", name: "read_file" },
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// tool_choice.name should be prefixed to match the prefixed tool names
expect(body.tool_choice).toEqual({ type: "tool", name: "oc_read_file" })
})
test("should not modify tool_choice when type is auto or any", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const tools = [
{
name: "read_file",
description: "Read a file",
input_schema: { type: "object" as const, properties: {} },
},
]
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Hello" }],
tools,
toolChoice: { type: "any" },
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// tool_choice with type "any" should be unchanged
expect(body.tool_choice).toEqual({ type: "any" })
})
test("should strip prefix from tool names in streaming responses", async () => {
// Simulate a tool_use response from the API with prefixed name
const sseData = [

View file

@ -85,6 +85,28 @@ function prefixToolNamesInMessages(messages: Anthropic.Messages.MessageParam[]):
})
}
/**
* Prefixes tool name in tool_choice when type is "tool".
* This ensures consistency with the prefixed tool names in the tools array.
*/
function prefixToolChoice(
toolChoice: Anthropic.Messages.ToolChoice | undefined,
): Anthropic.Messages.ToolChoice | undefined {
if (!toolChoice) {
return toolChoice
}
// Only prefix when tool_choice specifies a specific tool by name
if (toolChoice.type === "tool" && "name" in toolChoice) {
return {
...toolChoice,
name: prefixToolName(toolChoice.name),
}
}
return toolChoice
}
/**
* Filters out non-Anthropic content blocks from messages before sending to the API.
*
@ -487,9 +509,10 @@ export async function* createStreamingMessage(options: StreamMessageOptions): As
// when using Claude Code OAuth tokens
body.tools = prefixToolNames(tools)
// Default tool_choice to "auto" when tools are provided (as per spec example)
body.tool_choice = toolChoice || { type: "auto" }
// Prefix tool name in tool_choice if it specifies a specific tool
body.tool_choice = prefixToolChoice(toolChoice) || { type: "auto" }
} else if (toolChoice) {
body.tool_choice = toolChoice
body.tool_choice = prefixToolChoice(toolChoice)
}
// Build minimal headers