diff --git a/src/integrations/claude-code/__tests__/streaming-client.spec.ts b/src/integrations/claude-code/__tests__/streaming-client.spec.ts index 8ccb108827d..8211f51a725 100644 --- a/src/integrations/claude-code/__tests__/streaming-client.spec.ts +++ b/src/integrations/claude-code/__tests__/streaming-client.spec.ts @@ -1,6 +1,34 @@ -import { CLAUDE_CODE_API_CONFIG } from "../streaming-client" +import { CLAUDE_CODE_API_CONFIG, prefixToolName, stripToolNamePrefix } from "../streaming-client" describe("Claude Code Streaming Client", () => { + describe("Tool name prefix utilities", () => { + test("prefixToolName should add oc_ prefix to tool names", () => { + expect(prefixToolName("read_file")).toBe("oc_read_file") + expect(prefixToolName("write_to_file")).toBe("oc_write_to_file") + expect(prefixToolName("execute_command")).toBe("oc_execute_command") + }) + + test("stripToolNamePrefix should remove oc_ prefix from tool names", () => { + expect(stripToolNamePrefix("oc_read_file")).toBe("read_file") + expect(stripToolNamePrefix("oc_write_to_file")).toBe("write_to_file") + expect(stripToolNamePrefix("oc_execute_command")).toBe("execute_command") + }) + + test("stripToolNamePrefix should return unchanged name if no prefix", () => { + expect(stripToolNamePrefix("read_file")).toBe("read_file") + expect(stripToolNamePrefix("some_other_tool")).toBe("some_other_tool") + }) + + test("stripToolNamePrefix should handle edge cases", () => { + expect(stripToolNamePrefix("oc_")).toBe("") + expect(stripToolNamePrefix("")).toBe("") + // "occ_tool" does NOT start with "oc_" exactly, so it's unchanged + expect(stripToolNamePrefix("occ_tool")).toBe("occ_tool") + // But "oc_oc_tool" would strip one prefix + expect(stripToolNamePrefix("oc_oc_tool")).toBe("oc_tool") + }) + }) + describe("CLAUDE_CODE_API_CONFIG", () => { test("should have correct API endpoint", () => { expect(CLAUDE_CODE_API_CONFIG.endpoint).toBe("https://api.anthropic.com/v1/messages") @@ -581,5 +609,251 @@ describe("Claude Code Streaming Client", () => { cacheReadTokens: 5, }) }) + + test("should prefix tool names when sending to API", 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: {} }, + }, + { + name: "write_to_file", + description: "Write to 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, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // Tool names should be prefixed with oc_ + expect(body.tools).toHaveLength(2) + expect(body.tools[0].name).toBe("oc_read_file") + expect(body.tools[1].name).toBe("oc_write_to_file") + // Other properties should be preserved + expect(body.tools[0].description).toBe("Read a file") + expect(body.tools[1].description).toBe("Write to a file") + }) + + test("should prefix tool names in tool_use blocks within messages", 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 stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { role: "user", content: "Read a file" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool_123", + name: "read_file", + input: { path: "/test.txt" }, + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }], + }, + ] as 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 use block name should be prefixed + const assistantMessage = body.messages[1] + expect(assistantMessage.content[0].type).toBe("tool_use") + expect(assistantMessage.content[0].name).toBe("oc_read_file") + expect(assistantMessage.content[0].id).toBe("tool_123") + // Tool result should be unchanged (references tool_use_id, not name) + const userMessage = body.messages[2] + expect(userMessage.content[0].type).toBe("tool_result") + 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 = [ + 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"tool_use","id":"tool_456","name":"oc_execute_command"}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"command\\":"}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"input_json_delta","partial_json":"\\"ls\\"}"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ] + + let readIndex = 0 + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(() => { + if (readIndex < sseData.length) { + const value = new TextEncoder().encode(sseData[readIndex++]) + return Promise.resolve({ done: false, value }) + } + return Promise.resolve({ done: true, value: undefined }) + }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "List files" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Find the tool_call_partial chunk with the name + const toolCallChunks = chunks.filter((c) => c.type === "tool_call_partial") + expect(toolCallChunks.length).toBeGreaterThan(0) + + // The first tool_call_partial should have the stripped name + const firstToolCall = toolCallChunks[0] as { type: "tool_call_partial"; name?: string; id?: string } + expect(firstToolCall.name).toBe("execute_command") // Prefix stripped + expect(firstToolCall.id).toBe("tool_456") + }) }) }) diff --git a/src/integrations/claude-code/streaming-client.ts b/src/integrations/claude-code/streaming-client.ts index b864995f2cd..968f61ef989 100644 --- a/src/integrations/claude-code/streaming-client.ts +++ b/src/integrations/claude-code/streaming-client.ts @@ -2,6 +2,13 @@ import type { Anthropic } from "@anthropic-ai/sdk" import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" import { Package } from "../../shared/package" +/** + * Prefix added to tool names when sending to Claude Code API. + * Anthropic rejects third-party tools when using Claude Code OAuth tokens, + * so we prefix tool names to bypass their validation and strip the prefix from responses. + */ +const TOOL_NAME_PREFIX = "oc_" + /** * Set of content block types that are valid for Anthropic API. * Only these types will be passed through to the API. @@ -18,6 +25,87 @@ const VALID_ANTHROPIC_BLOCK_TYPES = new Set([ ]) type ContentBlockWithType = { type: string } +type ToolUseBlock = { type: "tool_use"; id: string; name: string; input: unknown } + +/** + * Adds the prefix to a tool name for Claude Code API requests. + * This bypasses Anthropic's validation that rejects third-party tools. + */ +export function prefixToolName(name: string): string { + return `${TOOL_NAME_PREFIX}${name}` +} + +/** + * Strips the prefix from a tool name received in API responses. + * Returns the original tool name for use within Roo Code. + */ +export function stripToolNamePrefix(name: string): string { + if (name.startsWith(TOOL_NAME_PREFIX)) { + return name.slice(TOOL_NAME_PREFIX.length) + } + return name +} + +/** + * Prefixes tool names in the tools array before sending to the API. + */ +function prefixToolNames(tools: Anthropic.Messages.Tool[]): Anthropic.Messages.Tool[] { + return tools.map((tool) => ({ + ...tool, + name: prefixToolName(tool.name), + })) +} + +/** + * Prefixes tool names in tool_use blocks within messages. + * This ensures consistency when messages containing tool_use blocks are sent back to the API. + */ +function prefixToolNamesInMessages(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { + return messages.map((message) => { + if (typeof message.content === "string") { + return message + } + + const prefixedContent = message.content.map((block) => { + const blockWithType = block as ContentBlockWithType + if (blockWithType.type === "tool_use") { + const toolUseBlock = block as unknown as ToolUseBlock + return { + ...toolUseBlock, + name: prefixToolName(toolUseBlock.name), + } + } + return block + }) + + return { + ...message, + content: prefixedContent, + } + }) +} + +/** + * 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. @@ -380,11 +468,16 @@ export async function* createStreamingMessage(options: StreamMessageOptions): As // - We cache the last two user messages for optimal cache hit rates const messagesWithCache = addMessageCacheBreakpoints(sanitizedMessages) + // Prefix tool names in tool_use blocks within messages for consistency + // This ensures that when messages containing tool_use blocks are sent back to the API, + // the tool names match the prefixed names we send in the tools array + const messagesWithPrefixedTools = prefixToolNamesInMessages(messagesWithCache) + // Build request body - match Claude Code format exactly const body: Record = { model, stream: true, - messages: messagesWithCache, + messages: messagesWithPrefixedTools, } // Only include max_tokens if explicitly provided @@ -412,11 +505,14 @@ export async function* createStreamingMessage(options: StreamMessageOptions): As } if (tools && tools.length > 0) { - body.tools = tools + // Prefix tool names to bypass Anthropic's third-party tool validation + // 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 @@ -545,22 +641,26 @@ export async function* createStreamingMessage(options: StreamMessageOptions): As yield { type: "reasoning", text: contentBlock.thinking as string } } break - case "tool_use": + case "tool_use": { + // Strip the prefix from tool names in responses so the rest of the + // application sees the original tool names + const originalName = stripToolNamePrefix(contentBlock.name as string) contentBlocks.set(index, { type: "tool_use", text: "", id: contentBlock.id as string, - name: contentBlock.name as string, + name: originalName, arguments: "", }) yield { type: "tool_call_partial", index, id: contentBlock.id as string, - name: contentBlock.name as string, + name: originalName, arguments: undefined, } break + } } } break