diff --git a/src/ui/components/messages/user-tool-result-message/FallbackToolResultMessage.tsx b/src/ui/components/messages/user-tool-result-message/FallbackToolResultMessage.tsx new file mode 100644 index 000000000..9419d6802 --- /dev/null +++ b/src/ui/components/messages/user-tool-result-message/FallbackToolResultMessage.tsx @@ -0,0 +1,52 @@ +import { Box, Text } from 'ink' +import * as React from 'react' +import { getTheme } from '@utils/theme' +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' + +const MAX_FALLBACK_CONTENT_LINES = 10 + +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd() +} + +function truncateContent(content: string): string { + const lines = normalizeLineEndings(content).split('\n') + if (lines.length <= MAX_FALLBACK_CONTENT_LINES) { + return lines.join('\n') + } + + return [...lines.slice(0, MAX_FALLBACK_CONTENT_LINES - 1), '...'].join('\n') +} + +function indentContent(content: string): string { + return content + .split('\n') + .map(line => ` ${line}`) + .join('\n') +} + +type Props = { + content: ToolResultBlockParam['content'] + verbose: boolean +} + +export function FallbackToolResultMessage({ + content, + verbose, +}: Props): React.ReactNode { + const textContent = + typeof content === 'string' + ? verbose + ? normalizeLineEndings(content) + : truncateContent(content) + : null + + return ( + + Tool result unavailable + {textContent ? ( + {indentContent(textContent)} + ) : null} + + ) +} diff --git a/src/ui/components/messages/user-tool-result-message/UserToolRejectMessage.tsx b/src/ui/components/messages/user-tool-result-message/UserToolRejectMessage.tsx index f7644f128..97e95c8f0 100644 --- a/src/ui/components/messages/user-tool-result-message/UserToolRejectMessage.tsx +++ b/src/ui/components/messages/user-tool-result-message/UserToolRejectMessage.tsx @@ -21,14 +21,20 @@ export function UserToolRejectMessage({ }: Props): React.ReactNode { const { columns } = useTerminalSize() const { conversationKey } = usePermissionContext() - const { tool, toolUse } = useGetToolFromMessages(toolUseID, tools, messages) - const input = tool.inputSchema.safeParse(toolUse.input) + const lookup = useGetToolFromMessages(toolUseID, tools, messages) + if (!lookup) { + return + } + + const input = lookup.tool.inputSchema.safeParse(lookup.toolUse.input) if (input.success) { - return tool.renderToolUseRejectedMessage(input.data, { - columns, - verbose, - conversationKey, - }) + return ( + lookup.tool.renderToolUseRejectedMessage?.(input.data, { + columns, + verbose, + conversationKey, + }) ?? + ) } return } diff --git a/src/ui/components/messages/user-tool-result-message/UserToolSuccessMessage.tsx b/src/ui/components/messages/user-tool-result-message/UserToolSuccessMessage.tsx index 5f96a6540..8dbe14fe3 100644 --- a/src/ui/components/messages/user-tool-result-message/UserToolSuccessMessage.tsx +++ b/src/ui/components/messages/user-tool-result-message/UserToolSuccessMessage.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import { Tool } from '@tool' import { Message, UserMessage } from '@query' import { useGetToolFromMessages } from './utils' +import { FallbackToolResultMessage } from './FallbackToolResultMessage' type Props = { param: ToolResultBlockParam @@ -22,13 +23,28 @@ export function UserToolSuccessMessage({ verbose, width, }: Props): React.ReactNode { - const { tool } = useGetToolFromMessages(param.tool_use_id, tools, messages) + const lookup = useGetToolFromMessages(param.tool_use_id, tools, messages) + + if ( + !lookup || + !lookup.tool.renderToolResultMessage || + !message.toolUseResult + ) { + return ( + + + + ) + } return ( - {tool.renderToolResultMessage?.(message.toolUseResult!.data as never, { - verbose, - })} + {lookup.tool.renderToolResultMessage( + message.toolUseResult.data as never, + { + verbose, + }, + )} ) } diff --git a/src/ui/components/messages/user-tool-result-message/utils.tsx b/src/ui/components/messages/user-tool-result-message/utils.tsx index d0b03e2f9..e33ed0c55 100644 --- a/src/ui/components/messages/user-tool-result-message/utils.tsx +++ b/src/ui/components/messages/user-tool-result-message/utils.tsx @@ -35,13 +35,11 @@ export function useGetToolFromMessages( toolUseID: string, tools: Tool[], messages: Message[], -) { +): { tool: Tool; toolUse: ToolUseBlockParam } | null { return useMemo(() => { const toolUse = getToolUseFromMessages(toolUseID, messages) if (!toolUse) { - throw new ReferenceError( - `Tool use not found for tool_use_id ${toolUseID}`, - ) + return null } const tool = [...tools, GlobTool, GrepTool].find( _ => _.name === toolUse.name, @@ -49,7 +47,7 @@ export function useGetToolFromMessages( if (tool === GlobTool || tool === GrepTool) { } if (!tool) { - throw new ReferenceError(`Tool not found for ${toolUse.name}`) + return null } return { tool, toolUse } }, [toolUseID, messages, tools]) diff --git a/tests/unit/user-tool-result-message-orphaned.test.tsx b/tests/unit/user-tool-result-message-orphaned.test.tsx new file mode 100644 index 000000000..c650e5174 --- /dev/null +++ b/tests/unit/user-tool-result-message-orphaned.test.tsx @@ -0,0 +1,248 @@ +import { describe, expect, test } from 'bun:test' +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { Box, Text, render } from 'ink' +import React from 'react' +import { PassThrough } from 'stream' +import stripAnsi from 'strip-ansi' +import { z } from 'zod' +import { PermissionProvider } from '@context/PermissionContext' +import { UserToolResultMessage } from '@components/messages/user-tool-result-message/UserToolResultMessage' +import type { Message, UserMessage } from '@query' +import type { Tool } from '@tool' +import { + createAssistantMessage, + createUserMessage, + REJECT_MESSAGE, +} from '@utils/messages' + +async function renderToText(element: React.ReactElement): Promise { + const stdin = new PassThrough() + ;(stdin as any).isTTY = true + ;(stdin as any).isRaw = true + ;(stdin as any).setRawMode = () => {} + stdin.setEncoding('utf8') + stdin.resume() + + const stdout = new PassThrough() + ;(stdout as any).isTTY = true + ;(stdout as any).columns = 100 + ;(stdout as any).rows = 30 + + let rawOutput = '' + stdout.on('data', chunk => { + rawOutput += chunk.toString('utf8') + }) + + const instance = render( + + {element} + , + { + stdin: stdin as any, + stdout: stdout as any, + exitOnCtrlC: false, + }, + ) + + await new Promise(resolve => setTimeout(resolve, 0)) + + instance.unmount() + return stripAnsi(rawOutput) +} + +function makeToolResultParam( + toolUseID: string, + content: ToolResultBlockParam['content'], + isError = false, +): ToolResultBlockParam { + return { + type: 'tool_result', + tool_use_id: toolUseID, + content, + is_error: isError, + } +} + +function makeToolResultMessage( + param: ToolResultBlockParam, + data?: unknown, +): UserMessage { + return createUserMessage( + [param] as any, + data === undefined + ? undefined + : { + data, + resultForAssistant: param.content, + }, + ) +} + +function makeToolUseMessage( + toolUseID: string, + name: string, + input: unknown = {}, +): Message { + const message = createAssistantMessage('ignored') as any + message.message.content = [ + { + type: 'tool_use', + id: toolUseID, + name, + input, + }, + ] + return message +} + +const inputSchema = z.object({}).passthrough() + +function makeTool(overrides: Partial> = {}) { + return { + name: 'FakeTool', + inputSchema, + async prompt() { + return '' + }, + async isEnabled() { + return true + }, + isReadOnly() { + return true + }, + isConcurrencySafe() { + return true + }, + needsPermissions() { + return false + }, + renderResultForAssistant() { + return '' + }, + renderToolUseMessage() { + return null + }, + async *call() { + yield { type: 'result', data: {} } + }, + ...overrides, + } satisfies Tool +} + +function renderToolResult(args: { + param: ToolResultBlockParam + message: UserMessage + messages: Message[] + tools?: Tool[] + verbose?: boolean +}) { + return renderToText( + , + ) +} + +describe('UserToolResultMessage orphaned fallback', () => { + test('renders orphaned successful tool_result without throwing', async () => { + const param = makeToolResultParam('missing-tool-use', 'first\nsecond') + const message = makeToolResultMessage(param) + + const out = await renderToolResult({ + param, + message, + messages: [message], + }) + + expect(out).toContain('Tool result unavailable') + expect(out).toContain('first') + expect(out).toContain('second') + expect(out).not.toContain('Tool use not found') + }) + + test('renders orphaned rejected tool_result with existing rejection fallback', async () => { + const param = makeToolResultParam('missing-tool-use', REJECT_MESSAGE, true) + const message = makeToolResultMessage(param) + + const out = await renderToolResult({ + param, + message, + messages: [message], + }) + + expect(out).toContain('No (tell') + expect(out).not.toContain('Tool use not found') + }) + + test('uses the matched tool renderer when the tool_use and tool exist', async () => { + const param = makeToolResultParam('tool-use-1', 'assistant content') + const message = makeToolResultMessage(param, { value: 'from data' }) + const toolUse = makeToolUseMessage('tool-use-1', 'FakeTool') + let rendered = false + const tool = makeTool({ + renderToolResultMessage(output) { + rendered = true + return custom result: {(output as any).value} + }, + }) + + const out = await renderToolResult({ + param, + message, + messages: [toolUse, message], + tools: [tool], + }) + + expect(rendered).toBe(true) + expect(out).toContain('custom result: from data') + expect(out).not.toContain('Tool result unavailable') + }) + + test('falls back when the tool_use exists but the tool is unavailable', async () => { + const param = makeToolResultParam('tool-use-2', 'raw content') + const message = makeToolResultMessage(param, { value: 'from data' }) + const toolUse = makeToolUseMessage('tool-use-2', 'MissingTool') + + const out = await renderToolResult({ + param, + message, + messages: [toolUse, message], + tools: [], + }) + + expect(out).toContain('Tool result unavailable') + expect(out).toContain('raw content') + expect(out).not.toContain('Tool not found') + }) + + test('truncates fallback string content only outside verbose mode', async () => { + const content = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join( + '\n', + ) + const param = makeToolResultParam('missing-tool-use', content) + const message = makeToolResultMessage(param) + + const terse = await renderToolResult({ + param, + message, + messages: [message], + verbose: false, + }) + const verbose = await renderToolResult({ + param, + message, + messages: [message], + verbose: true, + }) + + expect(terse).toContain('line 9') + expect(terse).toContain('...') + expect(terse).not.toContain('line 10') + expect(verbose).toContain('line 12') + }) +})