From 43efcfd39b3fd833074061c2f8192bc1358ea57e Mon Sep 17 00:00:00 2001 From: Pedro Ivo Date: Tue, 18 Aug 2026 13:04:19 -0300 Subject: [PATCH] fix(tools): reassemble fragmented tool names --- src/Tool.test.ts | 13 + src/Tool.ts | 43 +- src/components/Messages.tsx | 6 +- .../messages/AssistantToolUseMessage.tsx | 4 +- .../messages/CollapsedReadSearchContent.tsx | 4 +- .../messages/GroupedToolUseContent.tsx | 4 +- .../messages/UserToolResultMessage/utils.tsx | 4 +- src/components/tasks/renderToolActivity.tsx | 4 +- src/hooks/useDirectConnect.ts | 4 +- src/hooks/useInboxPoller.ts | 7 +- src/hooks/useRemoteSession.ts | 4 +- src/hooks/useSSHSession.ts | 4 +- src/query.ts | 4 +- src/services/api/codexShim.test.ts | 74 +++- src/services/api/codexShim.ts | 240 +++++++++-- src/services/api/openaiShim.test.ts | 401 +++++++++++++++++- src/services/api/openaiShim.ts | 376 ++++++++++------ src/services/tools/StreamingToolExecutor.ts | 15 +- src/services/tools/toolOrchestration.ts | 10 +- src/tasks/LocalAgentTask/LocalAgentTask.tsx | 4 +- src/tools/AgentTool/UI.tsx | 4 +- src/utils/collapseReadSearch.ts | 6 +- src/utils/hooks.ts | 11 +- .../messages.toolNameNormalization.test.ts | 93 ++++ src/utils/messages.ts | 31 +- src/utils/queryHelpers.ts | 8 +- 26 files changed, 1133 insertions(+), 245 deletions(-) create mode 100644 src/utils/messages.toolNameNormalization.test.ts diff --git a/src/Tool.test.ts b/src/Tool.test.ts index d0947eee66..5b9137d2a5 100644 --- a/src/Tool.test.ts +++ b/src/Tool.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { findToolByNameOrUniquePrefix, type Tools } from './Tool.js' +import { getAllBaseTools } from './tools.js' const tools = [ { name: 'Read' }, @@ -37,4 +38,16 @@ describe('findToolByNameOrUniquePrefix', () => { ), ).toBeUndefined() }) + + test('recovers a missing final character for every registered built-in tool', () => { + const baseTools = getAllBaseTools() + + for (const tool of baseTools) { + if (tool.name.length < 4 || tool.name.startsWith('mcp__')) continue + const truncatedName = tool.name.slice(0, -1) + expect( + findToolByNameOrUniquePrefix(baseTools, truncatedName)?.name, + ).toBe(tool.name) + } + }) }) diff --git a/src/Tool.ts b/src/Tool.ts index b5f7131308..3c220e4f8c 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -374,6 +374,33 @@ export function findToolByName(tools: Tools, name: string): Tool | undefined { return tools.find(t => toolMatchesName(t, name)) } +/** + * Resolves a canonical tool name from an exact match or an unambiguous + * provider-truncated prefix. This string-only form is shared by API adapters, + * which have the advertised tool schemas but not the runtime Tool objects. + */ +export function resolveToolNameByUniquePrefix( + toolNames: readonly string[], + name: string, +): string | undefined { + const uniqueToolNames = [...new Set(toolNames)] + if (uniqueToolNames.includes(name)) return name + + if (name.length < 3 || name.startsWith('mcp__')) return undefined + + const prefixMatches = uniqueToolNames.filter(toolName => + toolName.startsWith(name), + ) + const oneCharacterCompletions = prefixMatches.filter( + toolName => toolName.length === name.length + 1, + ) + if (oneCharacterCompletions.length === 1) { + return oneCharacterCompletions[0] + } + + return prefixMatches.length === 1 ? prefixMatches[0] : undefined +} + /** * Resolves a provider-truncated built-in tool name only when the prefix is * unambiguous. Prefer a unique one-character completion before considering @@ -388,17 +415,13 @@ export function findToolByNameOrUniquePrefix( const exactMatch = findToolByName(tools, name) if (exactMatch) return exactMatch - if (name.length < 3 || name.startsWith('mcp__')) return undefined - - const prefixMatches = tools.filter(tool => tool.name.startsWith(name)) - const oneCharacterCompletions = prefixMatches.filter( - tool => tool.name.length === name.length + 1, + const resolvedName = resolveToolNameByUniquePrefix( + tools.map(tool => tool.name), + name, ) - if (oneCharacterCompletions.length === 1) { - return oneCharacterCompletions[0] - } - - return prefixMatches.length === 1 ? prefixMatches[0] : undefined + return resolvedName + ? tools.find(tool => tool.name === resolvedName) + : undefined } export type Tool< diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index b53e357076..663bd4bba7 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -16,7 +16,7 @@ import { Box, Text } from '../ink.js'; import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; import type { Screen } from '../screens/REPL.js'; import type { Tools } from '../Tool.js'; -import { findToolByName } from '../Tool.js'; +import { findToolByNameOrUniquePrefix } from '../Tool.js'; import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'; import type { Message as MessageType, NormalizedMessage, ProgressMessage as ProgressMessageType, RenderableMessage } from '../types/message.js'; import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; @@ -589,7 +589,7 @@ const MessagesImpl = ({ const b_0 = msg_6.message.content[0]; if (b_0?.type !== 'tool_result' || b_0.is_error || !msg_6.toolUseResult) return false; const name = lookupsRef.current.toolUseByToolUseID.get(b_0.tool_use_id)?.name; - const tool = name ? findToolByName(tools, name) : undefined; + const tool = name ? findToolByNameOrUniquePrefix(tools, name) : undefined; return tool?.isResultTruncated?.(msg_6.toolUseResult as never) ?? false; }, [tools]); const canAnimate = (!toolJSX || !!toolJSX.shouldContinueAnimation) && !toolUseConfirmQueue.length && !isMessageSelectorVisible; @@ -658,7 +658,7 @@ const MessagesImpl = ({ const tr = msg_9.message.content.find(b_1 => b_1.type === 'tool_result'); if (tr && 'tool_use_id' in tr) { const tu = lookups_0.toolUseByToolUseID.get(tr.tool_use_id); - const tool_0 = tu && findToolByName(tools, tu.name); + const tool_0 = tu && findToolByNameOrUniquePrefix(tools, tu.name); const extracted = tool_0?.extractSearchText?.(msg_9.toolUseResult as never); // undefined = tool didn't implement → keep heuristic. Empty // string = tool says "nothing to index" → respect that. diff --git a/src/components/messages/AssistantToolUseMessage.tsx b/src/components/messages/AssistantToolUseMessage.tsx index ec32e4d880..8e52973db4 100644 --- a/src/components/messages/AssistantToolUseMessage.tsx +++ b/src/components/messages/AssistantToolUseMessage.tsx @@ -8,7 +8,7 @@ import { BLACK_CIRCLE } from '../../constants/figures.js'; import { stringWidth } from '../../ink/stringWidth.js'; import { Box, Text, useTheme } from '../../ink.js'; import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js'; -import { findToolByName, type Tool, type ToolProgressData, type Tools } from '../../Tool.js'; +import { findToolByNameOrUniquePrefix, type Tool, type ToolProgressData, type Tools } from '../../Tool.js'; import type { ProgressMessage } from '../../types/message.js'; import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js'; import { logError } from '../../utils/log.js'; @@ -64,7 +64,7 @@ export function AssistantToolUseMessage(t0) { t1 = null; break bb0; } - const tool = findToolByName(tools, param.name); + const tool = findToolByNameOrUniquePrefix(tools, param.name); if (!tool) { t1 = null; break bb0; diff --git a/src/components/messages/CollapsedReadSearchContent.tsx b/src/components/messages/CollapsedReadSearchContent.tsx index 8c9c92dc5c..a020ffddbd 100644 --- a/src/components/messages/CollapsedReadSearchContent.tsx +++ b/src/components/messages/CollapsedReadSearchContent.tsx @@ -4,7 +4,7 @@ import { basename } from 'path'; import React, { useRef } from 'react'; import { useMinDisplayTime } from '../../hooks/useMinDisplayTime.js'; import { Ansi, Box, Text, useTheme } from '../../ink.js'; -import { findToolByName, type Tools } from '../../Tool.js'; +import { findToolByNameOrUniquePrefix, type Tools } from '../../Tool.js'; import { getReplPrimitiveTools } from '../../tools/REPLTool/primitiveTools.js'; import type { CollapsedReadSearchGroup, NormalizedAssistantMessage } from '../../types/message.js'; import { uniq } from '../../utils/array.js'; @@ -55,7 +55,7 @@ function VerboseToolUse(t0) { if ($[0] !== bg || $[1] !== content.id || $[2] !== content.input || $[3] !== content.name || $[4] !== inProgressToolUseIDs || $[5] !== lookups || $[6] !== shouldAnimate || $[7] !== theme || $[8] !== tools) { t2 = Symbol.for("react.early_return_sentinel"); bb0: { - const tool = findToolByName(tools, content.name) ?? findToolByName(getReplPrimitiveTools(), content.name); + const tool = findToolByNameOrUniquePrefix(tools, content.name) ?? findToolByNameOrUniquePrefix(getReplPrimitiveTools(), content.name); if (!tool) { t2 = null; break bb0; diff --git a/src/components/messages/GroupedToolUseContent.tsx b/src/components/messages/GroupedToolUseContent.tsx index 449a152a78..5d2e730f78 100644 --- a/src/components/messages/GroupedToolUseContent.tsx +++ b/src/components/messages/GroupedToolUseContent.tsx @@ -1,6 +1,6 @@ import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'; import * as React from 'react'; -import { filterToolProgressMessages, findToolByName, type Tools } from '../../Tool.js'; +import { filterToolProgressMessages, findToolByNameOrUniquePrefix, type Tools } from '../../Tool.js'; import type { GroupedToolUseMessage } from '../../types/message.js'; import type { buildMessageLookups } from '../../utils/messages.js'; type Props = { @@ -17,7 +17,7 @@ export function GroupedToolUseContent({ inProgressToolUseIDs, shouldAnimate }: Props): React.ReactNode { - const tool = findToolByName(tools, message.toolName); + const tool = findToolByNameOrUniquePrefix(tools, message.toolName); if (!tool?.renderGroupedToolUse) { return null; } diff --git a/src/components/messages/UserToolResultMessage/utils.tsx b/src/components/messages/UserToolResultMessage/utils.tsx index 42e8efa283..f37803559e 100644 --- a/src/components/messages/UserToolResultMessage/utils.tsx +++ b/src/components/messages/UserToolResultMessage/utils.tsx @@ -1,7 +1,7 @@ import { c as _c } from "react-compiler-runtime"; import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; import { useMemo } from 'react'; -import { findToolByName, type Tool, type Tools } from '../../../Tool.js'; +import { findToolByNameOrUniquePrefix, type Tool, type Tools } from '../../../Tool.js'; import type { buildMessageLookups } from '../../../utils/messages.js'; export function useGetToolFromMessages(toolUseID, tools, lookups) { const $ = _c(7); @@ -13,7 +13,7 @@ export function useGetToolFromMessages(toolUseID, tools, lookups) { t0 = null; break bb0; } - const tool = findToolByName(tools, toolUse.name); + const tool = findToolByNameOrUniquePrefix(tools, toolUse.name); if (!tool) { t0 = null; break bb0; diff --git a/src/components/tasks/renderToolActivity.tsx b/src/components/tasks/renderToolActivity.tsx index e2e4ebae77..58548cdeb9 100644 --- a/src/components/tasks/renderToolActivity.tsx +++ b/src/components/tasks/renderToolActivity.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { Text } from '../../ink.js'; import type { Tools } from '../../Tool.js'; -import { findToolByName } from '../../Tool.js'; +import { findToolByNameOrUniquePrefix } from '../../Tool.js'; import type { ToolActivity } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; import type { ThemeName } from '../../utils/theme.js'; export function renderToolActivity(activity: ToolActivity, tools: Tools, theme: ThemeName): React.ReactNode { - const tool = findToolByName(tools, activity.toolName); + const tool = findToolByNameOrUniquePrefix(tools, activity.toolName); if (!tool) { return activity.toolName; } diff --git a/src/hooks/useDirectConnect.ts b/src/hooks/useDirectConnect.ts index 2fd1952033..2c4395d9ea 100644 --- a/src/hooks/useDirectConnect.ts +++ b/src/hooks/useDirectConnect.ts @@ -14,7 +14,7 @@ import { DirectConnectSessionManager, } from '../server/directConnectManager.js' import type { Tool } from '../Tool.js' -import { findToolByName } from '../Tool.js' +import { findToolByNameOrUniquePrefix } from '../Tool.js' import type { Message as MessageType } from '../types/message.js' import type { PermissionAskDecision } from '../types/permissions.js' import { logForDebugging } from '../utils/debug.js' @@ -90,7 +90,7 @@ export function useDirectConnect({ ) const tool = - findToolByName(toolsRef.current, request.tool_name) ?? + findToolByNameOrUniquePrefix(toolsRef.current, request.tool_name) ?? createToolStub(request.tool_name) const syntheticMessage = createSyntheticAssistantMessage( diff --git a/src/hooks/useInboxPoller.ts b/src/hooks/useInboxPoller.ts index 361ba636df..7a6541e829 100644 --- a/src/hooks/useInboxPoller.ts +++ b/src/hooks/useInboxPoller.ts @@ -11,7 +11,7 @@ import { useAppStateStore, useSetAppState, } from '../state/AppState.js' -import { findToolByName } from '../Tool.js' +import { findToolByNameOrUniquePrefix } from '../Tool.js' import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' import { getAllBaseTools } from '../tools.js' import type { PermissionUpdate } from '../types/permissions.js' @@ -267,7 +267,10 @@ export function useInboxPoller({ // Route through the standard ToolUseConfirmQueue so tmux workers // get the same tool-specific UI (BashPermissionRequest, FileEditToolDiff, etc.) // as in-process teammates. - const tool = findToolByName(getAllBaseTools(), parsed.tool_name) + const tool = findToolByNameOrUniquePrefix( + getAllBaseTools(), + parsed.tool_name, + ) if (!tool) { logForDebugging( `[InboxPoller] Unknown tool ${parsed.tool_name}, skipping permission request`, diff --git a/src/hooks/useRemoteSession.ts b/src/hooks/useRemoteSession.ts index 2a581a4b2d..ce8bdd175e 100644 --- a/src/hooks/useRemoteSession.ts +++ b/src/hooks/useRemoteSession.ts @@ -18,7 +18,7 @@ import { import { useSetAppState } from '../state/AppState.js' import type { AppState } from '../state/AppStateStore.js' import type { Tool } from '../Tool.js' -import { findToolByName } from '../Tool.js' +import { findToolByNameOrUniquePrefix } from '../Tool.js' import type { Message as MessageType } from '../types/message.js' import type { PermissionAskDecision } from '../types/permissions.js' import { logForDebugging } from '../utils/debug.js' @@ -338,7 +338,7 @@ export function useRemoteSession({ // Look up the Tool object by name, or create a stub for unknown tools const tool = - findToolByName(toolsRef.current, request.tool_name) ?? + findToolByNameOrUniquePrefix(toolsRef.current, request.tool_name) ?? createToolStub(request.tool_name) const syntheticMessage = createSyntheticAssistantMessage( diff --git a/src/hooks/useSSHSession.ts b/src/hooks/useSSHSession.ts index 35b3a067dc..1c454aa370 100644 --- a/src/hooks/useSSHSession.ts +++ b/src/hooks/useSSHSession.ts @@ -23,7 +23,7 @@ import { import type { SSHSession } from '../ssh/createSSHSession.js' import type { SSHSessionManager } from '../ssh/SSHSessionManager.js' import type { Tool } from '../Tool.js' -import { findToolByName } from '../Tool.js' +import { findToolByNameOrUniquePrefix } from '../Tool.js' import type { Message as MessageType } from '../types/message.js' import type { PermissionAskDecision } from '../types/permissions.js' import { logForDebugging } from '../utils/debug.js' @@ -94,7 +94,7 @@ export function useSSHSession({ ) const tool = - findToolByName(toolsRef.current, request.tool_name) ?? + findToolByNameOrUniquePrefix(toolsRef.current, request.tool_name) ?? createToolStub(request.tool_name) const syntheticMessage = createSyntheticAssistantMessage( diff --git a/src/query.ts b/src/query.ts index 0366338072..1843396193 100644 --- a/src/query.ts +++ b/src/query.ts @@ -25,7 +25,7 @@ import { } from 'src/services/analytics/index.js' import { ImageSizeError } from './utils/imageValidation.js' import { ImageResizeError } from './utils/imageResizer.js' -import { findToolByName, type ToolUseContext } from './Tool.js' +import { findToolByNameOrUniquePrefix, type ToolUseContext } from './Tool.js' import { asSystemPrompt, type SystemPrompt } from './utils/systemPromptType.js' import type { AssistantMessage, @@ -947,7 +947,7 @@ async function* queryLoop( typeof block.input === 'object' && block.input !== null ) { - const tool = findToolByName( + const tool = findToolByNameOrUniquePrefix( toolUseContext.options.tools, block.name, ) diff --git a/src/services/api/codexShim.test.ts b/src/services/api/codexShim.test.ts index aaa5e060da..8727c809a5 100644 --- a/src/services/api/codexShim.test.ts +++ b/src/services/api/codexShim.test.ts @@ -9,6 +9,7 @@ import { convertCodexResponseToAnthropicMessage, convertSystemPrompt, convertToolsToResponsesTools, + type AnthropicStreamEvent, } from './codexShim.js' import { __test as webSearchToolTest } from '../../tools/WebSearchTool/WebSearchTool.js' @@ -691,7 +692,7 @@ describe('Codex request translation', () => { ]) }) - test('converts completed Codex tool response into Anthropic message', () => { + test('canonicalizes a truncated completed Codex tool response', () => { const message = convertCodexResponseToAnthropicMessage( { id: 'resp_1', @@ -701,13 +702,14 @@ describe('Codex request translation', () => { type: 'function_call', id: 'fc_1', call_id: 'call_1', - name: 'ping', + name: 'pin', arguments: '{"value":"ping"}', }, ], usage: { input_tokens: 12, output_tokens: 4 }, }, 'gpt-5.3-codex-spark', + ['ping'], ) expect(message.stop_reason).toBe('tool_use') @@ -972,13 +974,13 @@ describe('Codex request translation', () => { ]) }) - test('translates interleaved parallel Codex calls without losing either ID', async () => { + test('keeps Responses API parallel calls ordered while an earlier name is incomplete', async () => { const responseText = [ 'event: response.output_item.added', - 'data: {"type":"response.output_item.added","item":{"id":"fc_a","call_id":"call_a","type":"function_call","name":"read","arguments":""},"output_index":0}', + 'data: {"type":"response.output_item.added","item":{"id":"fc_a","call_id":"call_a","type":"function_call","name":"Rea","arguments":""},"output_index":0}', '', 'event: response.output_item.added', - 'data: {"type":"response.output_item.added","item":{"id":"fc_b","call_id":"call_b","type":"function_call","name":"read","arguments":""},"output_index":1}', + 'data: {"type":"response.output_item.added","item":{"id":"fc_b","call_id":"call_b","type":"function_call","name":"Bash","arguments":""},"output_index":1}', '', 'event: response.function_call_arguments.delta', 'data: {"type":"response.function_call_arguments.delta","item_id":"fc_a","delta":"{\\"path\\":\\"a\\"}"}', @@ -987,13 +989,13 @@ describe('Codex request translation', () => { 'data: {"type":"response.function_call_arguments.delta","item_id":"fc_b","delta":"{\\"path\\":\\"b\\"}"}', '', 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"id":"fc_b","call_id":"call_b","type":"function_call","name":"read","arguments":"{\\"path\\":\\"b\\"}"},"output_index":1}', + 'data: {"type":"response.output_item.done","item":{"id":"fc_b","call_id":"call_b","type":"function_call","name":"Bash","arguments":"{\\"path\\":\\"b\\"}"},"output_index":1}', '', 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"id":"fc_a","call_id":"call_a","type":"function_call","name":"read","arguments":"{\\"path\\":\\"a\\"}"},"output_index":0}', + 'data: {"type":"response.output_item.done","item":{"id":"fc_a","call_id":"call_a","type":"function_call","name":"Rea","arguments":"{\\"path\\":\\"a\\"}"},"output_index":0}', '', 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_parallel","status":"completed","model":"gpt-5.4","output":[{"id":"fc_a","call_id":"call_a","type":"function_call","name":"read","arguments":"{\\"path\\":\\"a\\"}"},{"id":"fc_b","call_id":"call_b","type":"function_call","name":"read","arguments":"{\\"path\\":\\"b\\"}"}],"usage":{"input_tokens":2,"output_tokens":2}}}', + 'data: {"type":"response.completed","response":{"id":"resp_parallel","status":"completed","model":"gpt-5.4","output":[{"id":"fc_a","call_id":"call_a","type":"function_call","name":"Rea","arguments":"{\\"path\\":\\"a\\"}"},{"id":"fc_b","call_id":"call_b","type":"function_call","name":"Bash","arguments":"{\\"path\\":\\"b\\"}"}],"usage":{"input_tokens":2,"output_tokens":2}}}', '', ].join('\n') const stream = new ReadableStream({ @@ -1007,6 +1009,8 @@ describe('Codex request translation', () => { for await (const event of codexStreamToAnthropic( new Response(stream), 'gpt-5.4', + undefined, + ['Read', 'Bash'], )) { const contentBlock = event.content_block as | { type?: string; id?: string; name?: string } @@ -1025,11 +1029,61 @@ describe('Codex request translation', () => { } expect(toolUses).toEqual([ - { id: 'call_a', name: 'read' }, - { id: 'call_b', name: 'read' }, + { id: 'call_a', name: 'Read' }, + { id: 'call_b', name: 'Bash' }, ]) }) + test('recovers a truncated Responses API tool name before releasing buffered arguments', async () => { + const responseText = [ + 'event: response.output_item.added', + 'data: {"type":"response.output_item.added","item":{"id":"fc_read","call_id":"call_read","type":"function_call","name":"Rea","arguments":""},"output_index":0}', + '', + 'event: response.function_call_arguments.delta', + 'data: {"type":"response.function_call_arguments.delta","item_id":"fc_read","delta":"{\\"file_path\\":\\"README.md\\"}"}', + '', + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"id":"fc_read","call_id":"call_read","type":"function_call","name":"Rea","arguments":"{\\"file_path\\":\\"README.md\\"}"},"output_index":0}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_read","status":"completed","model":"gpt-5.4","output":[{"id":"fc_read","call_id":"call_read","type":"function_call","name":"Rea","arguments":"{\\"file_path\\":\\"README.md\\"}"}],"usage":{"input_tokens":2,"output_tokens":2}}}', + '', + ].join('\n') + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(responseText)) + controller.close() + }, + }) + const events: AnthropicStreamEvent[] = [] + + for await (const event of codexStreamToAnthropic( + new Response(stream), + 'gpt-5.4', + undefined, + ['Read'], + )) { + events.push(event) + } + + const toolStarts = events.filter( + event => + event.type === 'content_block_start' && + event.content_block?.type === 'tool_use', + ) + expect(toolStarts).toHaveLength(1) + expect(toolStarts[0]?.content_block).toMatchObject({ + id: 'call_read', + name: 'Read', + }) + + const input = events + .filter(event => event.delta?.type === 'input_json_delta') + .map(event => event.delta?.partial_json) + .join('') + expect(input).toBe('{"file_path":"README.md"}') + }) + test('strips tag block from Codex SSE text stream', async () => { const responseText = [ 'event: response.output_item.added', diff --git a/src/services/api/codexShim.ts b/src/services/api/codexShim.ts index 1fcbe5141d..303517e24f 100644 --- a/src/services/api/codexShim.ts +++ b/src/services/api/codexShim.ts @@ -1,4 +1,5 @@ import { APIError } from '@anthropic-ai/sdk' +import { resolveToolNameByUniquePrefix } from '../../Tool.js' import { buildAnthropicUsageFromRawUsage } from './cacheMetrics.js' import { compressToolHistory } from './compressToolHistory.js' import { fetchWithProxyRetry } from './fetchWithProxyRetry.js' @@ -787,11 +788,22 @@ export async function* codexStreamToAnthropic( response: Response, model: string, signal?: AbortSignal, + advertisedToolNames: readonly string[] = [], ): AsyncGenerator { const messageId = makeMessageId() const toolBlocksByItemId = new Map< string, - { index: number; toolUseId: string } + { + index: number + toolUseId: string + name: string + startedName?: string + argumentsBuffer: string + emittedArgumentsLength: number + hasStarted: boolean + isDone: boolean + hasStopped: boolean + } >() let activeTextBlockIndex: number | null = null const thinkFilter = createThinkTagFilter() @@ -829,6 +841,150 @@ export async function* codexStreamToAnthropic( } } + type ActiveCodexToolBlock = NonNullable< + ReturnType + > + + const findToolBlockEntry = ( + item: Record, + ): [string, ActiveCodexToolBlock] | undefined => { + for (const candidate of [item.id, item.call_id]) { + if (candidate == null) continue + const itemId = String(candidate) + const toolBlock = toolBlocksByItemId.get(itemId) + if (toolBlock) return [itemId, toolBlock] + } + return undefined + } + + const toolNameMayBeIncomplete = (name: string): boolean => + Boolean(name) && + !advertisedToolNames.includes(name) && + advertisedToolNames.some(toolName => toolName.startsWith(name)) + + const canonicalizeFinalToolName = (toolBlock: ActiveCodexToolBlock): void => { + const resolvedName = resolveToolNameByUniquePrefix( + advertisedToolNames, + toolBlock.name, + ) + if (resolvedName) toolBlock.name = resolvedName + } + + const applyFinalToolItem = ( + toolBlock: ActiveCodexToolBlock, + item: Record, + ): void => { + if (typeof item.name === 'string' && item.name) { + toolBlock.name = item.name + } + if ( + typeof item.arguments === 'string' && + (!toolBlock.hasStarted || + item.arguments.startsWith(toolBlock.argumentsBuffer)) + ) { + toolBlock.argumentsBuffer = item.arguments + } + canonicalizeFinalToolName(toolBlock) + } + + const emitPendingToolArguments = async function* ( + toolBlock: ActiveCodexToolBlock, + ) { + if ( + !toolBlock.hasStarted || + toolBlock.emittedArgumentsLength >= toolBlock.argumentsBuffer.length + ) { + return + } + + yield { + type: 'content_block_delta', + index: toolBlock.index, + delta: { + type: 'input_json_delta', + partial_json: toolBlock.argumentsBuffer.slice( + toolBlock.emittedArgumentsLength, + ), + }, + } + toolBlock.emittedArgumentsLength = toolBlock.argumentsBuffer.length + } + + const startToolBlock = async function* ( + toolBlock: ActiveCodexToolBlock, + force = false, + ) { + if (toolBlock.hasStarted) return + if (!force && (!toolBlock.name || toolNameMayBeIncomplete(toolBlock.name))) { + return + } + + if (force) canonicalizeFinalToolName(toolBlock) + toolBlock.hasStarted = true + toolBlock.startedName = toolBlock.name || 'tool' + + yield { + type: 'content_block_start', + index: toolBlock.index, + content_block: { + type: 'tool_use', + id: toolBlock.toolUseId, + name: toolBlock.startedName, + input: {}, + }, + } + yield* emitPendingToolArguments(toolBlock) + } + + const flushToolBlocks = async function* (force = false) { + const orderedBlocks = [...toolBlocksByItemId.values()].sort( + (a, b) => a.index - b.index, + ) + + for (const toolBlock of orderedBlocks) { + if (toolBlock.hasStopped) continue + + if (!toolBlock.hasStarted) { + yield* startToolBlock(toolBlock, force || toolBlock.isDone) + // Do not release a later parallel block before an earlier block whose + // name is still incomplete. + if (!toolBlock.hasStarted) break + } + + if ( + toolBlock.isDone && + toolBlock.startedName !== (toolBlock.name || 'tool') + ) { + toolBlock.startedName = toolBlock.name || 'tool' + yield { + type: 'content_block_start', + index: toolBlock.index, + content_block: { + type: 'tool_use', + id: toolBlock.toolUseId, + name: toolBlock.startedName, + input: {}, + }, + } + } + + yield* emitPendingToolArguments(toolBlock) + if (toolBlock.isDone) { + yield { + type: 'content_block_stop', + index: toolBlock.index, + } + toolBlock.hasStopped = true + } + } + } + + const removeStoppedToolBlocks = (): void => { + for (const [itemId, toolBlock] of toolBlocksByItemId) { + if (toolBlock.hasStopped) toolBlocksByItemId.delete(itemId) + } + } + yield { type: 'message_start', message: { @@ -852,33 +1008,20 @@ export async function* codexStreamToAnthropic( yield* closeActiveTextBlock() const blockIndex = nextContentBlockIndex++ const toolUseId = item.call_id ?? item.id ?? `call_${blockIndex}` - toolBlocksByItemId.set(String(item.id ?? toolUseId), { + const toolBlock = { index: blockIndex, toolUseId, - }) - sawToolUse = true - - yield { - type: 'content_block_start', - index: blockIndex, - content_block: { - type: 'tool_use', - id: toolUseId, - name: item.name ?? 'tool', - input: {}, - }, - } - - if (item.arguments) { - yield { - type: 'content_block_delta', - index: blockIndex, - delta: { - type: 'input_json_delta', - partial_json: item.arguments, - }, - } + name: typeof item.name === 'string' ? item.name : '', + argumentsBuffer: + typeof item.arguments === 'string' ? item.arguments : '', + emittedArgumentsLength: 0, + hasStarted: false, + isDone: false, + hasStopped: false, } + toolBlocksByItemId.set(String(item.id ?? toolUseId), toolBlock) + sawToolUse = true + yield* flushToolBlocks() } continue } @@ -911,14 +1054,10 @@ export async function* codexStreamToAnthropic( if (event.event === 'response.function_call_arguments.delta') { const toolBlock = toolBlocksByItemId.get(String(payload.item_id ?? '')) if (toolBlock) { - yield { - type: 'content_block_delta', - index: toolBlock.index, - delta: { - type: 'input_json_delta', - partial_json: payload.delta ?? '', - }, + if (typeof payload.delta === 'string') { + toolBlock.argumentsBuffer += payload.delta } + yield* flushToolBlocks() } continue } @@ -926,13 +1065,13 @@ export async function* codexStreamToAnthropic( if (event.event === 'response.output_item.done') { const item = payload.item if (item?.type === 'function_call') { - const toolBlock = toolBlocksByItemId.get(String(item.id ?? '')) - if (toolBlock) { - yield { - type: 'content_block_stop', - index: toolBlock.index, - } - toolBlocksByItemId.delete(String(item.id)) + const toolBlockEntry = findToolBlockEntry(item) + if (toolBlockEntry) { + const [, toolBlock] = toolBlockEntry + applyFinalToolItem(toolBlock, item) + toolBlock.isDone = true + yield* flushToolBlocks() + removeStoppedToolBlocks() } } else if (item?.type === 'message') { yield* closeActiveTextBlock() @@ -956,12 +1095,20 @@ export async function* codexStreamToAnthropic( } yield* closeActiveTextBlock() + const finalOutput = Array.isArray(finalResponse?.output) + ? finalResponse.output + : [] + for (const item of finalOutput) { + if (item?.type !== 'function_call') continue + const toolBlockEntry = findToolBlockEntry(item) + if (!toolBlockEntry) continue + applyFinalToolItem(toolBlockEntry[1], item) + } for (const toolBlock of toolBlocksByItemId.values()) { - yield { - type: 'content_block_stop', - index: toolBlock.index, - } + toolBlock.isDone = true } + yield* flushToolBlocks(true) + removeStoppedToolBlocks() yield { type: 'message_delta', @@ -984,6 +1131,7 @@ export async function* codexStreamToAnthropic( export function convertCodexResponseToAnthropicMessage( data: Record, model: string, + advertisedToolNames: readonly string[] = [], ): Record { const content: Array> = [] const output = Array.isArray(data.output) ? data.output : [] @@ -1002,6 +1150,10 @@ export function convertCodexResponseToAnthropicMessage( } if (item?.type === 'function_call') { + const toolName = + resolveToolNameByUniquePrefix(advertisedToolNames, item.name ?? '') ?? + item.name ?? + 'tool' let input: unknown try { input = JSON.parse(item.arguments ?? '{}') @@ -1012,7 +1164,7 @@ export function convertCodexResponseToAnthropicMessage( content.push({ type: 'tool_use', id: item.call_id ?? item.id ?? makeMessageId(), - name: item.name ?? 'tool', + name: toolName, input, }) } diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index e703fed080..0cb5d5ee2b 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -3,6 +3,7 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' +import { getAllBaseTools } from '../../tools.ts' import { registerGateway } from '../../integrations/index.ts' import { VERBOO_ROUTER_URL } from '../../constants/oauth.js' import { createOpenAIShimClient } from './openaiShim.ts' @@ -620,9 +621,11 @@ test('uses OpenAI-compatible responses endpoint when OPENAI_API_FORMAT=responses model: 'gpt-5.4', output: [ { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'ok' }], + type: 'function_call', + id: 'fc_read', + call_id: 'call_read', + name: 'Rea', + arguments: '{"file_path":"README.md"}', }, ], usage: { @@ -643,13 +646,24 @@ test('uses OpenAI-compatible responses endpoint when OPENAI_API_FORMAT=responses defaultHeaders: {}, }) as OpenAIShimClient - await client.beta.messages.create({ + const message = (await client.beta.messages.create({ model: 'gpt-5.4', system: 'test system', messages: [{ role: 'user', content: 'hello' }], + tools: [ + { + name: 'Read', + description: 'Read a file.', + input_schema: { + type: 'object', + properties: { file_path: { type: 'string' } }, + required: ['file_path'], + }, + }, + ], max_tokens: 64, stream: false, - }) + })) as { content?: Array> } expect(capturedUrl).toBe('http://example.test/v1/responses') expect(capturedBody?.model).toBe('gpt-5.4') @@ -663,6 +677,14 @@ test('uses OpenAI-compatible responses endpoint when OPENAI_API_FORMAT=responses content: [{ type: 'input_text', text: 'hello' }], }, ]) + expect(message.content).toEqual([ + { + type: 'tool_use', + id: 'call_read', + name: 'Read', + input: { file_path: 'README.md' }, + }, + ]) }) test('strips store from strict OpenAI-compatible responses providers', async () => { @@ -2335,7 +2357,336 @@ test('preserves Gemini tool call extra_content from streaming chunks', async () }) }) -test('normalizes plain string Bash tool arguments from OpenAI-compatible responses', async () => { +test('reassembles tool names split across OpenAI streaming chunks', async () => { + globalThis.fetch = (async () => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-split-name', + object: 'chat.completion.chunk', + model: 'qwen3.6-27b', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_read_1', + type: 'function', + function: { name: 'Rea', arguments: '{"file_path":' }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-split-name', + object: 'chat.completion.chunk', + model: 'qwen3.6-27b', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_read_1', + type: 'function', + function: { name: 'd' }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-split-name', + object: 'chat.completion.chunk', + model: 'qwen3.6-27b', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + type: 'function', + function: { name: 'Read' }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-split-name', + object: 'chat.completion.chunk', + model: 'qwen3.6-27b', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + type: 'function', + function: { arguments: '"README.md"}' }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-split-name', + object: 'chat.completion.chunk', + model: 'qwen3.6-27b', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + }, + ], + }, + ]) + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'qwen3.6-27b', + messages: [{ role: 'user', content: 'Read README.md' }], + tools: [ + { + name: 'Read', + description: 'Read a file from disk.', + input_schema: { + type: 'object', + properties: { file_path: { type: 'string' } }, + required: ['file_path'], + }, + }, + ], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) events.push(event) + + const toolStarts = events.filter( + event => + event.type === 'content_block_start' && + (event as { content_block?: { type?: string } }).content_block?.type === + 'tool_use', + ) as Array<{ index: number; content_block: Record }> + expect(toolStarts).toHaveLength(1) + expect(toolStarts[0]).toMatchObject({ + index: 0, + content_block: { id: 'call_read_1', name: 'Read' }, + }) + + const input = events + .filter( + event => + event.type === 'content_block_delta' && + (event as { delta?: { type?: string } }).delta?.type === + 'input_json_delta', + ) + .map(event => (event as { delta: { partial_json: string } }).delta.partial_json) + .join('') + expect(input).toBe('{"file_path":"README.md"}') + expect( + events.filter( + event => event.type === 'content_block_stop' && event.index === 0, + ), + ).toHaveLength(1) +}) + +test('keeps fragmented parallel tool calls correlated and ordered', async () => { + globalThis.fetch = (async () => { + const chunk = ( + toolCalls: Array>, + finishReason: string | null = null, + ) => ({ + id: 'chatcmpl-parallel-names', + object: 'chat.completion.chunk', + model: 'qwen3.6-27b', + choices: [ + { + index: 0, + delta: toolCalls.length > 0 ? { tool_calls: toolCalls } : {}, + finish_reason: finishReason, + }, + ], + }) + + return makeSseResponse( + makeStreamChunks([ + chunk([ + { index: 0, id: 'call_read', type: 'function' }, + { index: 1, id: 'call_bash', type: 'function' }, + ]), + chunk([ + { index: 0, type: 'function', function: { name: 'Rea' } }, + { index: 1, type: 'function', function: { name: 'Ba' } }, + ]), + chunk([ + { index: 1, type: 'function', function: { name: 'sh' } }, + { index: 0, type: 'function', function: { name: 'd' } }, + ]), + chunk([ + { + index: 1, + type: 'function', + function: { arguments: '{"command":"pwd"}' }, + }, + ]), + chunk([ + { + index: 0, + type: 'function', + function: { arguments: '{"file_path":"README.md"}' }, + }, + ]), + chunk([], 'tool_calls'), + ]), + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'qwen3.6-27b', + messages: [{ role: 'user', content: 'Inspect the project' }], + tools: [ + { + name: 'Read', + description: 'Read a file.', + input_schema: { type: 'object', properties: {} }, + }, + { + name: 'Bash', + description: 'Run a command.', + input_schema: { type: 'object', properties: {} }, + }, + ], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) events.push(event) + + const toolStarts = events + .filter( + event => + event.type === 'content_block_start' && + (event as { content_block?: { type?: string } }).content_block?.type === + 'tool_use', + ) + .map(event => ({ + index: event.index, + id: (event as { content_block: { id: string } }).content_block.id, + name: (event as { content_block: { name: string } }).content_block.name, + })) + + expect(toolStarts).toEqual([ + { index: 0, id: 'call_read', name: 'Read' }, + { index: 1, id: 'call_bash', name: 'Bash' }, + ]) +}) + +test('recovers a missing final character for every advertised built-in tool', async () => { + const toolNames = getAllBaseTools() + .map(tool => tool.name) + .filter(name => name.length >= 4 && !name.startsWith('mcp__')) + + globalThis.fetch = (async () => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-all-truncated-tools', + object: 'chat.completion.chunk', + model: 'qwen3.6-27b', + choices: [ + { + index: 0, + delta: { + tool_calls: toolNames.map((name, index) => ({ + index, + id: `call_${index}`, + type: 'function', + function: { + name: name.slice(0, -1), + arguments: '{}', + }, + })), + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-all-truncated-tools', + object: 'chat.completion.chunk', + model: 'qwen3.6-27b', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + }, + ], + }, + ]) + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'qwen3.6-27b', + messages: [{ role: 'user', content: 'Exercise available tools' }], + tools: toolNames.map(name => ({ + name, + description: `Exercise ${name}.`, + input_schema: { type: 'object', properties: {} }, + })), + max_tokens: 64, + stream: true, + }) + .withResponse() + + const starts: Array<{ id: string; name: string }> = [] + for await (const event of result.data) { + const contentBlock = event.content_block as + | { type?: string; id?: string; name?: string } + | undefined + if ( + event.type === 'content_block_start' && + contentBlock?.type === 'tool_use' && + typeof contentBlock.id === 'string' && + typeof contentBlock.name === 'string' + ) { + starts.push({ id: contentBlock.id, name: contentBlock.name }) + } + } + + expect(starts).toEqual( + toolNames.map((name, index) => ({ id: `call_${index}`, name })), + ) +}) + +test('recovers a terminally truncated mapped tool name before non-streaming argument normalization', async () => { globalThis.fetch = (async (_input, _init) => { return new Response( JSON.stringify({ @@ -2350,7 +2701,7 @@ test('normalizes plain string Bash tool arguments from OpenAI-compatible respons id: 'function-call-1', type: 'function', function: { - name: 'Bash', + name: 'Bas', arguments: 'pwd', }, }, @@ -2379,6 +2730,17 @@ test('normalizes plain string Bash tool arguments from OpenAI-compatible respons model: 'google/gemini-3.1-pro-preview', system: 'test system', messages: [{ role: 'user', content: 'Use Bash' }], + tools: [ + { + name: 'Bash', + description: 'Run a shell command.', + input_schema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, + }, + ], max_tokens: 64, stream: false, })) as { @@ -2584,7 +2946,7 @@ test('keeps terminal empty Bash tool arguments invalid in non-streaming response ]) }) -test('normalizes plain string Bash tool arguments in streaming responses', async () => { +test('normalizes mapped tool arguments when the stream ends with a truncated name', async () => { globalThis.fetch = (async (_input, _init) => { const chunks = makeStreamChunks([ { @@ -2602,7 +2964,7 @@ test('normalizes plain string Bash tool arguments in streaming responses', async id: 'function-call-1', type: 'function', function: { - name: 'Bash', + name: 'Bas', arguments: 'pwd', }, }, @@ -2636,6 +2998,17 @@ test('normalizes plain string Bash tool arguments in streaming responses', async model: 'google/gemini-3.1-pro-preview', system: 'test system', messages: [{ role: 'user', content: 'Use Bash' }], + tools: [ + { + name: 'Bash', + description: 'Run a shell command.', + input_schema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, + }, + ], max_tokens: 64, stream: true, }) @@ -2657,6 +3030,16 @@ test('normalizes plain string Bash tool arguments in streaming responses', async .map((event) => (event.delta as Record).partial_json) .join('') + const toolStarts = events.filter( + event => + event.type === 'content_block_start' && + (event as { content_block?: { type?: string } }).content_block?.type === + 'tool_use', + ) + expect(toolStarts).toHaveLength(1) + expect(toolStarts[0]).toMatchObject({ + content_block: { name: 'Bash' }, + }) expect(normalizedInput).toBe('{"command":"pwd"}') }) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 39da149378..d03577e8c1 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -27,6 +27,7 @@ import { randomUUID } from 'crypto' import { APIError } from '@anthropic-ai/sdk' +import { resolveToolNameByUniquePrefix } from '../../Tool.js' import { getSessionId } from '../../bootstrap/state.js' import { isVerbooMode, VERBOO_ROUTER_URL } from '../../constants/oauth.js' import { @@ -1040,6 +1041,40 @@ function repairPossiblyTruncatedObjectJson(raw: string): string | null { } } +type ActiveOpenAIToolCall = { + id: string + name: string + index: number + jsonBuffer: string + emittedJsonLength: number + normalizeAtStop: boolean + hasStarted: boolean + readyToStart: boolean + startedName?: string + extra_content?: Record +} + +/** + * OpenAI-compatible providers are allowed to stream function names as deltas. + * Some also repeat the cumulative/full name on later chunks, so support both + * shapes without turning `Rea` + `d` into a permanently truncated `Rea`. + */ +function mergeStreamedToolName(current: string, fragment: string): string { + if (!fragment) return current + if (!current || fragment.startsWith(current)) return fragment + return current + fragment +} + +function getAdvertisedToolNames(params: ShimCreateParams): string[] { + return (params.tools ?? []).flatMap(tool => + typeof tool.name === 'string' && + tool.name && + tool.name !== 'ToolSearchTool' + ? [tool.name] + : [], + ) +} + /** * Async generator that transforms an OpenAI SSE stream into * Anthropic-format BetaRawMessageStreamEvent objects. @@ -1158,20 +1193,11 @@ async function* openaiStreamToAnthropic( model: string, signal?: AbortSignal, warmingHint?: WarmingHintController, + advertisedToolNames: readonly string[] = [], ): AsyncGenerator { const messageId = makeMessageId() let contentBlockIndex = 0 - const activeToolCalls = new Map< - number, - { - id: string - name: string - index: number - jsonBuffer: string - normalizeAtStop: boolean - extra_content?: Record - } - >() + const activeToolCalls = new Map() let hasEmittedContentStart = false let hasEmittedThinkingStart = false let hasClosedThinking = false @@ -1287,6 +1313,99 @@ async function* openaiStreamToAnthropic( hasEmittedContentStart = false } + const toolNameMayBeIncomplete = (name: string): boolean => + Boolean(name) && + !advertisedToolNames.includes(name) && + advertisedToolNames.some(toolName => toolName.startsWith(name)) + + const canonicalizeFinalToolName = (toolCall: ActiveOpenAIToolCall): void => { + const resolvedName = resolveToolNameByUniquePrefix( + advertisedToolNames, + toolCall.name, + ) + if (resolvedName) toolCall.name = resolvedName + } + + const startToolCall = async function* ( + toolCall: ActiveOpenAIToolCall, + force = false, + ) { + if (toolCall.hasStarted || !toolCall.id || !toolCall.name) return + if (!force && toolNameMayBeIncomplete(toolCall.name)) return + + if (force) canonicalizeFinalToolName(toolCall) + + toolCall.normalizeAtStop = hasToolFieldMapping(toolCall.name) + toolCall.hasStarted = true + toolCall.startedName = toolCall.name + + yield { + type: 'content_block_start' as const, + index: toolCall.index, + content_block: { + type: 'tool_use' as const, + id: toolCall.id, + name: toolCall.name, + input: {}, + ...(toolCall.extra_content + ? { extra_content: toolCall.extra_content } + : {}), + ...((toolCall.extra_content?.google as any)?.thought_signature + ? { + signature: (toolCall.extra_content?.google as any) + .thought_signature, + } + : {}), + }, + } + + if (!toolCall.normalizeAtStop && toolCall.jsonBuffer) { + yield { + type: 'content_block_delta' as const, + index: toolCall.index, + delta: { + type: 'input_json_delta' as const, + partial_json: toolCall.jsonBuffer, + }, + } + toolCall.emittedJsonLength = toolCall.jsonBuffer.length + } + } + + const flushReadyToolCalls = async function* (force = false) { + const orderedCalls = [...activeToolCalls.values()].sort( + (a, b) => a.index - b.index, + ) + + for (const toolCall of orderedCalls) { + if (!toolCall.hasStarted) { + if (!force && !toolCall.readyToStart) break + yield* startToolCall(toolCall, force) + if (!toolCall.hasStarted) { + if (!force) break + continue + } + } + + if ( + !toolCall.normalizeAtStop && + toolCall.emittedJsonLength < toolCall.jsonBuffer.length + ) { + yield { + type: 'content_block_delta' as const, + index: toolCall.index, + delta: { + type: 'input_json_delta' as const, + partial_json: toolCall.jsonBuffer.slice( + toolCall.emittedJsonLength, + ), + }, + } + toolCall.emittedJsonLength = toolCall.jsonBuffer.length + } + } + } + try { while (true) { const { done, value } = await readWithTimeout() @@ -1317,7 +1436,9 @@ async function* openaiStreamToAnthropic( yield { type: 'content_block_stop', index: contentBlockIndex } } for (const [, toolCall] of activeToolCalls) { - yield { type: 'content_block_stop', index: toolCall.index } + if (toolCall.hasStarted) { + yield { type: 'content_block_stop', index: toolCall.index } + } } activeToolCalls.clear() throw new Error( @@ -1382,7 +1503,9 @@ async function* openaiStreamToAnthropic( yield { type: 'content_block_stop', index: contentBlockIndex } } for (const [, toolCall] of activeToolCalls) { - yield { type: 'content_block_stop', index: toolCall.index } + if (toolCall.hasStarted) { + yield { type: 'content_block_stop', index: toolCall.index } + } } activeToolCalls.clear() // Do NOT yield message_stop here — the synthetic API error @@ -1468,8 +1591,11 @@ async function* openaiStreamToAnthropic( // Tool calls if (delta.tool_calls) { for (const tc of delta.tool_calls) { - if (tc.id && tc.function?.name) { - // New tool call starting — close any open thinking block first + let active = activeToolCalls.get(tc.index) + if (!active) { + // A tool call may arrive as separate id, name, and argument + // chunks. Reserve its block once, keyed by the OpenAI index, + // instead of requiring id + name to be co-located. if (hasEmittedThinkingStart && !hasClosedThinking) { yield { type: 'content_block_stop', @@ -1482,114 +1608,64 @@ async function* openaiStreamToAnthropic( yield* closeActiveContentBlock() } - const toolBlockIndex = contentBlockIndex - const initialArguments = tc.function.arguments ?? '' - const normalizeAtStop = hasToolFieldMapping(tc.function.name) - processStreamChunk(streamState, tc.function.arguments ?? '') - - // Capture extra_content / thought_signature (may be top-level or nested) - const topLevelSig = (tc as any).thought_signature as - string | undefined - const initEC: Record | undefined = - tc.extra_content - ? { ...tc.extra_content } - : topLevelSig - ? { google: { thought_signature: topLevelSig } } - : undefined - - activeToolCalls.set(tc.index, { - id: tc.id, - name: tc.function.name, - index: toolBlockIndex, - jsonBuffer: initialArguments, - normalizeAtStop, - extra_content: initEC, - }) - - yield { - type: 'content_block_start', - index: toolBlockIndex, - content_block: { - type: 'tool_use', - id: tc.id, - name: tc.function.name, - input: {}, - ...(initEC ? { extra_content: initEC } : {}), - ...((initEC?.google as any)?.thought_signature - ? { signature: (initEC.google as any).thought_signature } - : {}), - }, + active = { + id: '', + name: '', + index: contentBlockIndex++, + jsonBuffer: '', + emittedJsonLength: 0, + normalizeAtStop: false, + hasStarted: false, + readyToStart: false, } - contentBlockIndex++ + activeToolCalls.set(tc.index, active) + } - // Emit any initial arguments - if (tc.function.arguments && !normalizeAtStop) { - yield { - type: 'content_block_delta', - index: toolBlockIndex, - delta: { - type: 'input_json_delta', - partial_json: tc.function.arguments, - }, - } - } - } else if (tc.function?.arguments) { - // Continuation of existing tool call - const active = activeToolCalls.get(tc.index) - if (active) { - if (tc.function.arguments) { - active.jsonBuffer += tc.function.arguments - } + if (tc.id && !active.id) { + active.id = tc.id + } - // Also capture extra_content/thought_signature if bundled with args - const contSig = (tc as any).thought_signature as - string | undefined - const contEC = tc.extra_content - ? { ...tc.extra_content } - : contSig - ? { google: { thought_signature: contSig } } - : undefined - if (contEC) { - active.extra_content = { - ...(active.extra_content ?? {}), - ...contEC, - } - } + const nameFragment = tc.function?.name + if (nameFragment) { + active.name = mergeStreamedToolName( + active.name, + nameFragment, + ) + } - if (active.normalizeAtStop) { - continue - } + const argumentFragment = tc.function?.arguments + if (typeof argumentFragment === 'string') { + active.jsonBuffer += argumentFragment + processStreamChunk(streamState, argumentFragment) + } - yield { - type: 'content_block_delta', - index: active.index, - delta: { - type: 'input_json_delta', - partial_json: tc.function.arguments, - }, - } - } - } else { - // Chunk with only extra_content / thought_signature (Gemini thinking models - // may send thought_signature in a separate chunk from id/name/arguments) - const active = activeToolCalls.get(tc.index) - if (active) { - const lateSig = (tc as any).thought_signature as - string | undefined - const lateEC = tc.extra_content - ? { ...tc.extra_content } - : lateSig - ? { google: { thought_signature: lateSig } } - : undefined - if (lateEC) { - active.extra_content = { - ...(active.extra_content ?? {}), - ...lateEC, - } - } + // Capture extra_content / thought_signature whether it arrives + // with the initial metadata, arguments, or in its own chunk. + const thoughtSignature = (tc as any).thought_signature as + string | undefined + const extraContent = tc.extra_content + ? { ...tc.extra_content } + : thoughtSignature + ? { google: { thought_signature: thoughtSignature } } + : undefined + if (extraContent) { + active.extra_content = { + ...(active.extra_content ?? {}), + ...extraContent, } } + + // An empty arguments scaffold can arrive before the final name + // fragment (`Rea`, then `d`). A non-blank argument is the first + // reliable boundary after the streamed function name. + if (argumentFragment?.trim()) { + active.readyToStart = true + } } + + // Preserve the provider's tool-call order even when argument + // deltas for parallel calls arrive interleaved. + yield* flushReadyToolCalls() } // Finish — guard ensures we only process finish_reason once even if @@ -1608,11 +1684,35 @@ async function* openaiStreamToAnthropic( yield* closeActiveContentBlock() } // Close active tool calls + for (const toolCall of activeToolCalls.values()) { + canonicalizeFinalToolName(toolCall) + // Mapped tools buffer their raw provider arguments so they can + // be normalized once the complete name is known. Recompute the + // decision when no raw JSON has escaped yet. + if (toolCall.emittedJsonLength === 0) { + toolCall.normalizeAtStop = hasToolFieldMapping(toolCall.name) + } + } + const startedBeforeFinish = new Set( + [...activeToolCalls.values()] + .filter(toolCall => toolCall.hasStarted) + .map(toolCall => toolCall.index), + ) + yield* flushReadyToolCalls(true) for (const [, tc] of activeToolCalls) { + const wasStarted = startedBeforeFinish.has(tc.index) + if (!tc.hasStarted) { + continue + } + // Re-emit content_block_start with final extra_content so that // late-arriving thought_signature chunks (Gemini thinking models) - // are reflected in the stored message block before it is finalized. - if (tc.extra_content) { + // and any non-standard late name fragment are reflected in the + // stored message block before it is finalized. + if ( + wasStarted && + (tc.extra_content || tc.startedName !== tc.name) + ) { yield { type: 'content_block_start' as const, index: tc.index, @@ -1621,10 +1721,12 @@ async function* openaiStreamToAnthropic( id: tc.id, name: tc.name, input: {}, - extra_content: tc.extra_content, - ...((tc.extra_content.google as any)?.thought_signature + ...(tc.extra_content + ? { extra_content: tc.extra_content } + : {}), + ...((tc.extra_content?.google as any)?.thought_signature ? { - signature: (tc.extra_content.google as any) + signature: (tc.extra_content?.google as any) .thought_signature, } : {}), @@ -1661,6 +1763,18 @@ async function* openaiStreamToAnthropic( continue } + if (tc.emittedJsonLength < tc.jsonBuffer.length) { + yield { + type: 'content_block_delta', + index: tc.index, + delta: { + type: 'input_json_delta', + partial_json: tc.jsonBuffer.slice(tc.emittedJsonLength), + }, + } + tc.emittedJsonLength = tc.jsonBuffer.length + } + let suffixToAdd = '' if (tc.jsonBuffer) { try { @@ -1980,6 +2094,7 @@ class OpenAIShimMessages { options?: { signal?: AbortSignal; headers?: Record }, ) { const self = this + const advertisedToolNames = getAdvertisedToolNames(params) let httpResponse: Response | undefined @@ -2016,12 +2131,14 @@ class OpenAIShimMessages { response, request.resolvedModel, options?.signal, + advertisedToolNames, ) : openaiStreamToAnthropic( response, request.resolvedModel, options?.signal, warmingHint, + advertisedToolNames, ), warmingHint, ) @@ -2039,6 +2156,7 @@ class OpenAIShimMessages { return convertCodexResponseToAnthropicMessage( data, request.resolvedModel, + advertisedToolNames, ) } @@ -2059,11 +2177,13 @@ class OpenAIShimMessages { return convertCodexResponseToAnthropicMessage( parsed, request.resolvedModel, + advertisedToolNames, ) } return self._convertNonStreamingResponse( parsed, request.resolvedModel, + advertisedToolNames, ) } } @@ -2071,7 +2191,11 @@ class OpenAIShimMessages { const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { const data = await response.json() - return self._convertNonStreamingResponse(data, request.resolvedModel) + return self._convertNonStreamingResponse( + data, + request.resolvedModel, + advertisedToolNames, + ) } const textBody = await response.text().catch(() => '') @@ -3043,6 +3167,7 @@ class OpenAIShimMessages { } }, model: string, + advertisedToolNames: readonly string[] = [], ) { const choice = data.choices?.[0] const content: Array> = [] @@ -3086,14 +3211,19 @@ class OpenAIShimMessages { if (choice?.message?.tool_calls) { for (const tc of choice.message.tool_calls) { + const toolName = + resolveToolNameByUniquePrefix( + advertisedToolNames, + tc.function.name, + ) ?? tc.function.name const input = normalizeToolArguments( - tc.function.name, + toolName, tc.function.arguments, ) content.push({ type: 'tool_use', id: tc.id, - name: tc.function.name, + name: toolName, input, ...(tc.extra_content ? { extra_content: tc.extra_content } : {}), // Extract Gemini signature from extra_content diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index 20f5eb9be6..6408d16e20 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -6,7 +6,6 @@ import { } from 'src/utils/messages.js' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { - findToolByName, findToolByNameOrUniquePrefix, type Tools, type ToolUseContext, @@ -33,6 +32,7 @@ type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded' type TrackedTool = { id: string block: ToolUseBlock + canonicalName: string assistantMessage: AssistantMessage status: ToolStatus isConcurrencySafe: boolean @@ -106,6 +106,7 @@ export class StreamingToolExecutor { this.tools.push({ id: block.id, block, + canonicalName: block.name, assistantMessage, status: 'completed', isConcurrencySafe: true, @@ -141,6 +142,7 @@ export class StreamingToolExecutor { this.tools.push({ id: block.id, block, + canonicalName: toolDefinition.name, assistantMessage, status: 'queued', isConcurrencySafe, @@ -272,7 +274,10 @@ export class StreamingToolExecutor { } private getToolInterruptBehavior(tool: TrackedTool): 'cancel' | 'block' { - const definition = findToolByName(this.toolDefinitions, tool.block.name) + const definition = findToolByNameOrUniquePrefix( + this.toolDefinitions, + tool.canonicalName, + ) if (!definition?.interruptBehavior) return 'block' try { return definition.interruptBehavior() @@ -287,9 +292,9 @@ export class StreamingToolExecutor { if (typeof summary === 'string' && summary.length > 0) { const truncated = summary.length > 40 ? summary.slice(0, 40) + '\u2026' : summary - return `${tool.block.name}(${truncated})` + return `${tool.canonicalName}(${truncated})` } - return tool.block.name + return tool.canonicalName } private updateInterruptibleState(): void { @@ -397,7 +402,7 @@ export class StreamingToolExecutor { // Only Bash errors cancel siblings. Bash commands often have implicit // dependency chains (e.g. mkdir fails → subsequent commands pointless). // Read/WebFetch/etc are independent — one failure shouldn't nuke the rest. - if (tool.block.name === BASH_TOOL_NAME) { + if (tool.canonicalName === BASH_TOOL_NAME) { this.hasErrored = true this.erroredToolDescription = this.getToolDescription(tool) this.siblingAbortController.abort('sibling_error') diff --git a/src/services/tools/toolOrchestration.ts b/src/services/tools/toolOrchestration.ts index 95e02cbcc8..7af80eae8d 100644 --- a/src/services/tools/toolOrchestration.ts +++ b/src/services/tools/toolOrchestration.ts @@ -1,6 +1,9 @@ import type { ToolUseBlock } from '@anthropic-ai/sdk/resources/index.mjs' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' -import { findToolByName, type ToolUseContext } from '../../Tool.js' +import { + findToolByNameOrUniquePrefix, + type ToolUseContext, +} from '../../Tool.js' import type { AssistantMessage, Message } from '../../types/message.js' import { all } from '../../utils/generators.js' import { type MessageUpdateLazy, runToolUse } from './toolExecution.js' @@ -93,7 +96,10 @@ function partitionToolCalls( toolUseContext: ToolUseContext, ): Batch[] { return toolUseMessages.reduce((acc: Batch[], toolUse) => { - const tool = findToolByName(toolUseContext.options.tools, toolUse.name) + const tool = findToolByNameOrUniquePrefix( + toolUseContext.options.tools, + toolUse.name, + ) const parsedInput = tool?.inputSchema.safeParse(toolUse.input) const isConcurrencySafe = parsedInput?.success ? (() => { diff --git a/src/tasks/LocalAgentTask/LocalAgentTask.tsx b/src/tasks/LocalAgentTask/LocalAgentTask.tsx index 541e95fe3b..b2401c490d 100644 --- a/src/tasks/LocalAgentTask/LocalAgentTask.tsx +++ b/src/tasks/LocalAgentTask/LocalAgentTask.tsx @@ -5,7 +5,7 @@ import type { AppState } from '../../state/AppState.js'; import type { SetAppState, Task, TaskStateBase } from '../../Task.js'; import { createTaskStateBase } from '../../Task.js'; import type { Tools } from '../../Tool.js'; -import { findToolByName } from '../../Tool.js'; +import { findToolByNameOrUniquePrefix } from '../../Tool.js'; import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js'; import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js'; @@ -109,7 +109,7 @@ export function getProgressUpdate(tracker: ProgressTracker): AgentProgress { */ export function createActivityDescriptionResolver(tools: Tools): ActivityDescriptionResolver { return (toolName, input) => { - const tool = findToolByName(tools, toolName); + const tool = findToolByNameOrUniquePrefix(tools, toolName); return tool?.getActivityDescription?.(input) ?? undefined; }; } diff --git a/src/tools/AgentTool/UI.tsx b/src/tools/AgentTool/UI.tsx index 8889544267..ecae679b8d 100644 --- a/src/tools/AgentTool/UI.tsx +++ b/src/tools/AgentTool/UI.tsx @@ -15,7 +15,7 @@ import { MessageResponse } from '../../components/MessageResponse.js'; import { ToolUseLoader } from '../../components/ToolUseLoader.js'; import { Box, Text } from '../../ink.js'; import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; -import { findToolByName, type Tools } from '../../Tool.js'; +import { findToolByNameOrUniquePrefix, type Tools } from '../../Tool.js'; import type { Message, ProgressMessage } from '../../types/message.js'; import type { AgentToolProgress } from '../../types/tools.js'; import { count } from '../../utils/array.js'; @@ -841,7 +841,7 @@ export function extractLastToolInfo(progressMessages: ProgressMessage[ // Look up the corresponding tool_use — already indexed above const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); if (toolUseBlock) { - const tool = findToolByName(tools, toolUseBlock.name); + const tool = findToolByNameOrUniquePrefix(tools, toolUseBlock.name); if (!tool) { return toolUseBlock.name; // Fallback to raw name } diff --git a/src/utils/collapseReadSearch.ts b/src/utils/collapseReadSearch.ts index c4e330126b..68edb69f5f 100644 --- a/src/utils/collapseReadSearch.ts +++ b/src/utils/collapseReadSearch.ts @@ -1,6 +1,6 @@ import { feature } from 'bun:bundle' import type { UUID } from 'crypto' -import { findToolByName, type Tools } from '../Tool.js' +import { findToolByNameOrUniquePrefix, type Tools } from '../Tool.js' import { extractBashCommentLabel } from '../tools/BashTool/commentLabel.js' import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js' @@ -197,8 +197,8 @@ export function getToolSearchOrReadInfo( // messages. Without the fallback they'd return isCollapsible: false and // vanish from the summary line. const tool = - findToolByName(tools, toolName) ?? - findToolByName(getReplPrimitiveTools(), toolName) + findToolByNameOrUniquePrefix(tools, toolName) ?? + findToolByNameOrUniquePrefix(getReplPrimitiveTools(), toolName) if (!tool?.isSearchOrReadCommand) { return { isCollapsible: false, diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 0243695e02..7df17ebb92 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -146,7 +146,11 @@ import { } from './hooks/hookEvents.js' import { createAttachmentMessage } from './attachments.js' import { all } from './generators.js' -import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js' +import { + findToolByNameOrUniquePrefix, + type Tools, + type ToolUseContext, +} from '../Tool.js' import type { CanUseToolFn } from '../hooks/useCanUseTool.js' import { execPromptHook } from './hooks/execPromptHook.js' import type { Message, AssistantMessage } from '../types/message.js' @@ -1588,8 +1592,9 @@ async function prepareIfConditionMatcher( return undefined } - const toolName = normalizeLegacyToolName(hookInput.tool_name) - const tool = tools && findToolByName(tools, hookInput.tool_name) + const tool = + tools && findToolByNameOrUniquePrefix(tools, hookInput.tool_name) + const toolName = normalizeLegacyToolName(tool?.name ?? hookInput.tool_name) const input = tool?.inputSchema.safeParse(hookInput.tool_input) const patternMatcher = input?.success && tool?.preparePermissionMatcher diff --git a/src/utils/messages.toolNameNormalization.test.ts b/src/utils/messages.toolNameNormalization.test.ts new file mode 100644 index 0000000000..a588282560 --- /dev/null +++ b/src/utils/messages.toolNameNormalization.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from 'bun:test' + +import type { Tools } from '../Tool.js' +import type { Message } from '../types/message.js' +import { + normalizeContentFromAPI, + normalizeMessagesForAPI, +} from './messages.js' + +function toolNames(...names: string[]): Tools { + return names.map(name => ({ name })) as unknown as Tools +} + +function toolUse(name: string) { + return [ + { + type: 'tool_use' as const, + id: 'call_1', + name, + input: '{"file_path":"README.md"}', + }, + ] +} + +test('canonicalizes an unambiguous terminal tool-name prefix', () => { + const [normalized] = normalizeContentFromAPI( + toolUse('Rea'), + toolNames('Read', 'ReadMcpResourceTool'), + ) + + expect(normalized).toMatchObject({ + type: 'tool_use', + name: 'Read', + input: { file_path: 'README.md' }, + }) +}) + +test('does not canonicalize ambiguous, aliased, or MCP tool names', () => { + const [ambiguous] = normalizeContentFromAPI( + toolUse('Rea'), + toolNames('Read', 'Real'), + ) + const [aliased] = normalizeContentFromAPI( + toolUse('Rea'), + [{ name: 'Reader', aliases: ['Rea'] }] as unknown as Tools, + ) + const [mcp] = normalizeContentFromAPI( + toolUse('mcp__files__rea'), + toolNames('mcp__files__read'), + ) + + expect(ambiguous).toMatchObject({ name: 'Rea' }) + expect(aliased).toMatchObject({ name: 'Rea' }) + expect(mcp).toMatchObject({ name: 'mcp__files__rea' }) +}) + +test('canonicalizes truncated tool names when replaying an existing transcript', () => { + const [normalized] = normalizeMessagesForAPI( + [ + { + type: 'assistant', + uuid: 'message_1', + timestamp: '2026-08-18T00:00:00.000Z', + message: { + id: 'message_1', + type: 'message', + role: 'assistant', + model: 'test-model', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'Rea', + input: { file_path: 'README.md' }, + }, + ], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + }, + }, + }, + ] as unknown as Message[], + toolNames('Read', 'ReadMcpResourceTool'), + ) + + expect(normalized?.message.content[0]).toMatchObject({ + type: 'tool_use', + name: 'Read', + }) +}) diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 488f4d5a18..b208f7480e 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -132,10 +132,9 @@ import { } from '../constants/xml.js' import { DiagnosticTrackingService } from '../services/diagnosticTracking.js' import { - findToolByName, + findToolByNameOrUniquePrefix, type Tool, type Tools, - toolMatchesName, } from '../Tool.js' import { FileReadTool, @@ -2237,7 +2236,10 @@ export function normalizeMessagesForAPI( ...message.message, content: message.message.content.map(block => { if (block.type === 'tool_use') { - const tool = tools.find(t => toolMatchesName(t, block.name)) + const tool = findToolByNameOrUniquePrefix( + tools, + block.name, + ) const normalizedInput = tool ? normalizeToolInputForAPI( tool, @@ -2726,13 +2728,27 @@ export function normalizeContentFromAPI( normalizedInput = contentBlock.input } - // Then apply tool-specific corrections + // Then apply tool-specific corrections. The OpenAI-compatible stream + // adapter normally reconstructs fragmented names before this point, + // but retain a conservative final defense for providers that actually + // terminate with an unambiguous built-in prefix (`Rea` -> `Read`). + const resolvedTool = findToolByNameOrUniquePrefix( + tools, + contentBlock.name, + ) + const normalizedToolName = + resolvedTool && + resolvedTool.name !== contentBlock.name && + !resolvedTool.aliases?.includes(contentBlock.name) && + resolvedTool.name.startsWith(contentBlock.name) + ? resolvedTool.name + : contentBlock.name + if (typeof normalizedInput === 'object' && normalizedInput !== null) { - const tool = findToolByName(tools, contentBlock.name) - if (tool) { + if (resolvedTool) { try { normalizedInput = normalizeToolInput( - tool, + resolvedTool, normalizedInput as { [key: string]: unknown }, agentId, ) @@ -2745,6 +2761,7 @@ export function normalizeContentFromAPI( return { ...contentBlock, + name: normalizedToolName, input: normalizedInput, } } diff --git a/src/utils/queryHelpers.ts b/src/utils/queryHelpers.ts index a3661bafbb..bbc0f96385 100644 --- a/src/utils/queryHelpers.ts +++ b/src/utils/queryHelpers.ts @@ -7,7 +7,11 @@ import { import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js' import type { CanUseToolFn } from '../hooks/useCanUseTool.js' import { runTools } from '../services/tools/toolOrchestration.js' -import { findToolByName, type Tool, type Tools } from '../Tool.js' +import { + findToolByNameOrUniquePrefix, + type Tool, + type Tools, +} from '../Tool.js' import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js' import type { Input as FileReadInput } from '../tools/FileReadTool/FileReadTool.js' @@ -253,7 +257,7 @@ export async function* handleOrphanedPermission( const toolName = toolUseBlock.name const toolInput = toolUseBlock.input - const toolDefinition = findToolByName(tools, toolName) + const toolDefinition = findToolByNameOrUniquePrefix(tools, toolName) if (!toolDefinition) { return }