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')
+ })
+})