Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<Box flexDirection="column">
<Text color={getTheme().secondaryText}> Tool result unavailable</Text>
{textContent ? (
<Text wrap="wrap">{indentContent(textContent)}</Text>
) : null}
</Box>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FallbackToolUseRejectedMessage />
}

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,
}) ?? <FallbackToolUseRejectedMessage />
)
}
return <FallbackToolUseRejectedMessage />
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<Box flexDirection="column" width={width}>
<FallbackToolResultMessage content={param.content} verbose={verbose} />
</Box>
)
}

return (
<Box flexDirection="column" width={width}>
{tool.renderToolResultMessage?.(message.toolUseResult!.data as never, {
verbose,
})}
{lookup.tool.renderToolResultMessage(
message.toolUseResult.data as never,
{
verbose,
},
)}
</Box>
)
}
8 changes: 3 additions & 5 deletions src/ui/components/messages/user-tool-result-message/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,19 @@ 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,
)
if (tool === GlobTool || tool === GrepTool) {
}
if (!tool) {
throw new ReferenceError(`Tool not found for ${toolUse.name}`)
return null
}
return { tool, toolUse }
}, [toolUseID, messages, tools])
Expand Down
248 changes: 248 additions & 0 deletions tests/unit/user-tool-result-message-orphaned.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string> {
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(
<PermissionProvider conversationKey="tool-result-test">
<Box>{element}</Box>
</PermissionProvider>,
{
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<Tool<typeof inputSchema, unknown>> = {}) {
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<typeof inputSchema, unknown>
}

function renderToolResult(args: {
param: ToolResultBlockParam
message: UserMessage
messages: Message[]
tools?: Tool[]
verbose?: boolean
}) {
return renderToText(
<UserToolResultMessage
param={args.param}
message={args.message}
messages={args.messages}
tools={args.tools ?? []}
verbose={args.verbose ?? false}
width={80}
/>,
)
}

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 <Text>custom result: {(output as any).value}</Text>
},
})

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