diff --git a/src/Tool.test.ts b/src/Tool.test.ts new file mode 100644 index 0000000000..f772d701e1 --- /dev/null +++ b/src/Tool.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' + +import { findToolByNameOrUniquePrefix, type Tools } from './Tool.js' + +const tools = [ + { name: 'Read' }, + { name: 'Bash' }, + { name: 'Grep' }, +] as Tools + +describe('findToolByNameOrUniquePrefix', () => { + test('recovers an unambiguous provider-truncated built-in tool name', () => { + expect(findToolByNameOrUniquePrefix(tools, 'Rea')?.name).toBe('Read') + }) + + test('does not guess short, ambiguous, or MCP tool names', () => { + expect(findToolByNameOrUniquePrefix(tools, 'R')).toBeUndefined() + expect( + findToolByNameOrUniquePrefix( + [{ name: 'Read' }, { name: 'Ready' }] as Tools, + 'Rea', + ), + ).toBeUndefined() + expect( + findToolByNameOrUniquePrefix( + [{ name: 'mcp__files__read' }] as Tools, + 'mcp__files__rea', + ), + ).toBeUndefined() + }) +}) diff --git a/src/Tool.ts b/src/Tool.ts index e3d36405ea..a94ad6045d 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -374,6 +374,25 @@ export function findToolByName(tools: Tools, name: string): Tool | undefined { return tools.find(t => toolMatchesName(t, name)) } +/** + * Resolves a provider-truncated built-in tool name only when the prefix is + * unambiguous among tools already available to the model. This keeps malformed + * calls such as `Rea` recoverable as `Read` without guessing MCP tool names or + * accepting very short, collision-prone prefixes. + */ +export function findToolByNameOrUniquePrefix( + tools: Tools, + name: string, +): Tool | undefined { + 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)) + return prefixMatches.length === 1 ? prefixMatches[0] : undefined +} + export type Tool< Input extends AnyObject = AnyObject, Output = unknown, diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index b5767e381b..c7a280a1ce 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2335,6 +2335,114 @@ test('preserves Gemini tool call extra_content from streaming chunks', async () }) }) +test('assembles function names split across streaming tool-call deltas', async () => { + globalThis.fetch = (async (_input, _init) => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-split-tool-name', + object: 'chat.completion.chunk', + model: 'max/deepseek-v4-pro', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: 'function-call-split-name', + type: 'function', + function: { + name: 'Rea', + arguments: '', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-split-tool-name', + object: 'chat.completion.chunk', + model: 'max/deepseek-v4-pro', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { + name: 'd', + arguments: '{"file_path":"/tmp/example.ts"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-split-tool-name', + object: 'chat.completion.chunk', + model: 'max/deepseek-v4-pro', + 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: 'max/deepseek-v4-pro', + system: 'test system', + messages: [{ role: 'user', content: 'Read the example file' }], + 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' && + typeof event.content_block === 'object' && + event.content_block !== null && + (event.content_block as Record).type === 'tool_use', + ) as Array<{ content_block: Record }> + const inputDeltas = events.filter( + (event) => + event.type === 'content_block_delta' && + typeof event.delta === 'object' && + event.delta !== null && + (event.delta as Record).type === 'input_json_delta', + ) as Array<{ delta: { partial_json: string } }> + + expect(toolStarts.at(-1)?.content_block).toMatchObject({ + type: 'tool_use', + id: 'function-call-split-name', + name: 'Read', + }) + expect(inputDeltas.map((event) => event.delta.partial_json).join('')).toBe( + '{"file_path":"/tmp/example.ts"}', + ) +}) + test('normalizes plain string Bash tool arguments from OpenAI-compatible responses', async () => { globalThis.fetch = (async (_input, _init) => { return new Response( diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index bacc8527b9..0b3f5d85da 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1169,6 +1169,7 @@ async function* openaiStreamToAnthropic( index: number jsonBuffer: string normalizeAtStop: boolean + nameWasContinued: boolean extra_content?: Record } >() @@ -1503,6 +1504,7 @@ async function* openaiStreamToAnthropic( index: toolBlockIndex, jsonBuffer: initialArguments, normalizeAtStop, + nameWasContinued: false, extra_content: initEC, }) @@ -1516,7 +1518,10 @@ async function* openaiStreamToAnthropic( input: {}, ...(initEC ? { extra_content: initEC } : {}), ...((initEC?.google as any)?.thought_signature - ? { signature: (initEC.google as any).thought_signature } + ? { + signature: (initEC?.google as any) + .thought_signature, + } : {}), }, } @@ -1533,12 +1538,23 @@ async function* openaiStreamToAnthropic( }, } } - } else if (tc.function?.arguments) { - // Continuation of existing tool call + } else { + // Continuation of an existing tool call. OpenAI-compatible + // providers may split function names across SSE deltas (for + // example `Rea` then `d`). Preserve both name and argument + // fragments instead of executing a truncated unknown tool. const active = activeToolCalls.get(tc.index) if (active) { - if (tc.function.arguments) { - active.jsonBuffer += tc.function.arguments + const functionNameFragment = tc.function?.name + const argumentFragment = tc.function?.arguments + + if (functionNameFragment) { + active.name += functionNameFragment + active.nameWasContinued = true + } + + if (argumentFragment) { + active.jsonBuffer += argumentFragment } // Also capture extra_content/thought_signature if bundled with args @@ -1556,7 +1572,7 @@ async function* openaiStreamToAnthropic( } } - if (active.normalizeAtStop) { + if (!argumentFragment || active.normalizeAtStop) { continue } @@ -1565,29 +1581,10 @@ async function* openaiStreamToAnthropic( index: active.index, delta: { type: 'input_json_delta', - partial_json: tc.function.arguments, + partial_json: argumentFragment, }, } } - } 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, - } - } - } } } } @@ -1609,10 +1606,10 @@ async function* openaiStreamToAnthropic( } // Close active tool calls for (const [, tc] of activeToolCalls) { - // 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) { + // Re-emit content_block_start when late metadata or function-name + // fragments arrived, so the stored block has the final tool name + // and any provider-specific signature before it is finalized. + if (tc.extra_content || tc.nameWasContinued) { yield { type: 'content_block_start' as const, index: tc.index, @@ -1621,10 +1618,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, } : {}), diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index 2f698b43a6..a570934921 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -5,7 +5,12 @@ import { withMemoryCorrectionHint, } from 'src/utils/messages.js' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' -import { findToolByName, type Tools, type ToolUseContext } from '../../Tool.js' +import { + findToolByNameOrUniquePrefix, + type Tools, + type ToolUseContext, +} from '../../Tool.js' +import { getAllBaseTools } from '../../tools.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import type { AssistantMessage, Message } from '../../types/message.js' import { createChildAbortController } from '../../utils/abortController.js' @@ -80,7 +85,22 @@ export class StreamingToolExecutor { * Add a tool to the execution queue. Will start executing immediately if conditions allow. */ addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void { - const toolDefinition = findToolByName(this.toolDefinitions, block.name) + let toolDefinition = findToolByNameOrUniquePrefix( + this.toolDefinitions, + block.name, + ) + if (!toolDefinition) { + const baseTool = findToolByNameOrUniquePrefix( + getAllBaseTools(), + block.name, + ) + if (baseTool && !baseTool.isMcp && baseTool.name !== block.name) { + toolDefinition = baseTool + logForDebugging( + `Recovered truncated base tool name ${block.name} as ${baseTool.name}: ${block.id}`, + ) + } + } if (!toolDefinition) { this.tools.push({ id: block.id, diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index 27acd1ec16..9f96a05236 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -29,6 +29,7 @@ import { import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { findToolByName, + findToolByNameOrUniquePrefix, type Tool, type ToolProgress, type ToolProgressData, @@ -388,16 +389,39 @@ export async function* runToolUse( ): AsyncGenerator { const toolName = toolUse.name // First try to find in the available tools (what the model sees) - let tool = findToolByName(toolUseContext.options.tools, toolName) + let tool = findToolByNameOrUniquePrefix( + toolUseContext.options.tools, + toolName, + ) + + if (tool && tool.name !== toolName) { + logForDebugging( + `Recovered truncated tool name ${toolName} as ${tool.name}: ${toolUse.id}`, + ) + } // If not found, check if it's a deprecated tool being called by alias // (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop") // Only fall back for tools where the name matches an alias, not the primary name if (!tool) { - const fallbackTool = findToolByName(getAllBaseTools(), toolName) - // Only use fallback if the tool was found via alias (deprecated name) - if (fallbackTool && fallbackTool.aliases?.includes(toolName)) { + const fallbackTool = findToolByNameOrUniquePrefix( + getAllBaseTools(), + toolName, + ) + // A model can only call a tool it received in its schema. Recover an exact + // alias or an unambiguous truncated built-in name when filtering removed + // the original definition from this execution path. + if ( + fallbackTool && + (fallbackTool.aliases?.includes(toolName) || + (!fallbackTool.isMcp && fallbackTool.name !== toolName)) + ) { tool = fallbackTool + if (tool.name !== toolName) { + logForDebugging( + `Recovered truncated base tool name ${toolName} as ${tool.name}: ${toolUse.id}`, + ) + } } } const messageId = assistantMessage.message.id diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index e3c8362190..236ea181c4 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -64,6 +64,7 @@ import { } from 'src/types/textInputTypes.js' import { randomUUID, type UUID } from 'crypto' import { getSettings_DEPRECATED } from './settings/settings.js' +import type { EffortValue } from './effort.js' import { getSnippetForTwoFileDiff } from 'src/tools/FileEditTool/utils.js' import type { ContentBlockParam, @@ -606,6 +607,7 @@ export type Attachment = type: 'command_permissions' allowedTools: string[] model?: string + effort?: EffortValue } | AgentMentionAttachment | { diff --git a/src/utils/handlePromptSubmit.ts b/src/utils/handlePromptSubmit.ts index 461306a805..40f8e41c12 100644 --- a/src/utils/handlePromptSubmit.ts +++ b/src/utils/handlePromptSubmit.ts @@ -28,6 +28,7 @@ import { enqueue } from './messageQueueManager.js' import { resolveSkillModelOverride } from './model/model.js' import type { ProcessUserInputContext } from './processUserInput/processUserInput.js' import { processUserInput } from './processUserInput/processUserInput.js' +import { getActiveSkillScopeForQueuedContinuation } from './processUserInput/activeSkillScope.js' import type { QueryGuard } from './QueryGuard.js' import { queryCheckpoint, startQueryProfile } from './queryProfiler.js' import { runWithWorkload } from './workloadContext.js' @@ -553,6 +554,13 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise { const primaryCmd = commands[0] const primaryMode = primaryCmd?.mode ?? 'prompt' + const inheritedSkillScope = getActiveSkillScopeForQueuedContinuation( + messages, + primaryMode, + ) + allowedTools ??= inheritedSkillScope?.allowedTools + model ??= inheritedSkillScope?.model + effort ??= inheritedSkillScope?.effort const primaryInput = primaryCmd && typeof primaryCmd.value === 'string' ? primaryCmd.value diff --git a/src/utils/processUserInput/activeSkillScope.test.ts b/src/utils/processUserInput/activeSkillScope.test.ts new file mode 100644 index 0000000000..2aa1c349a7 --- /dev/null +++ b/src/utils/processUserInput/activeSkillScope.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test' +import { getActiveSkillScopeForQueuedContinuation } from './activeSkillScope.js' + +const scope = (overrides: Record = {}) => ({ + type: 'attachment', + attachment: { + type: 'command_permissions', + allowedTools: ['Read', 'Agent'], + model: 'gpt-5.6-sol', + effort: 'high', + ...overrides, + }, +}) + +describe('getActiveSkillScopeForQueuedContinuation', () => { + test('restores the latest skill scope for a task notification', () => { + expect( + getActiveSkillScopeForQueuedContinuation( + [ + { type: 'user', isMeta: false }, + scope(), + { type: 'assistant' }, + { + type: 'user', + origin: { kind: 'task-notification' }, + }, + ] as never, + 'task-notification', + ), + ).toEqual({ + allowedTools: ['Read', 'Agent'], + model: 'gpt-5.6-sol', + effort: 'high', + }) + }) + + test('uses the newest nested skill scope', () => { + expect( + getActiveSkillScopeForQueuedContinuation( + [ + scope(), + scope({ + allowedTools: ['Read', 'Bash'], + model: 'max/deepseek-v4-pro', + effort: 'medium', + }), + ] as never, + 'task-notification', + ), + ).toEqual({ + allowedTools: ['Read', 'Bash'], + model: 'max/deepseek-v4-pro', + effort: 'medium', + }) + }) + + test('does not apply a skill scope to an ordinary user turn', () => { + expect( + getActiveSkillScopeForQueuedContinuation( + [scope()] as never, + 'prompt', + ), + ).toBeNull() + }) + + test('does not revive a skill after a later visible user prompt', () => { + expect( + getActiveSkillScopeForQueuedContinuation( + [scope(), { type: 'user', isMeta: false }] as never, + 'task-notification', + ), + ).toBeNull() + }) +}) diff --git a/src/utils/processUserInput/activeSkillScope.ts b/src/utils/processUserInput/activeSkillScope.ts new file mode 100644 index 0000000000..241becd204 --- /dev/null +++ b/src/utils/processUserInput/activeSkillScope.ts @@ -0,0 +1,50 @@ +import type { Message } from '../../types/message.js' +import type { EffortValue } from '../effort.js' + +export type ActiveSkillScope = { + allowedTools: string[] + model?: string + effort?: EffortValue +} + +/** + * Background task notifications continue the workflow that launched the task. + * Recover the most recent inline skill scope so the notification turn keeps + * the skill's model, effort, and tool permissions instead of silently falling + * back to the session defaults. + * + * A later visible user prompt ends the scope. This prevents an old background + * task from reviving a skill after the user has moved on to another request. + */ +export function getActiveSkillScopeForQueuedContinuation( + messages: Message[], + mode: string | undefined, +): ActiveSkillScope | null { + if (mode !== 'task-notification') return null + + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (!message) continue + + if ( + message.type === 'attachment' && + message.attachment?.type === 'command_permissions' + ) { + return { + allowedTools: message.attachment.allowedTools ?? [], + model: message.attachment.model, + effort: message.attachment.effort, + } + } + + if ( + message.type === 'user' && + !message.isMeta && + message.origin?.kind !== 'task-notification' + ) { + return null + } + } + + return null +} diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index a325213cc3..d347b3d749 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -907,7 +907,8 @@ async function getMessagesForPromptSlashCommand(command: CommandBase & PromptCom }), ...attachmentMessages, createAttachmentMessage({ type: 'command_permissions', allowedTools: additionalAllowedTools, - model: command.model + model: command.model, + effort: command.effort })]; return { messages,