fix: prevent null output and string render crashes in MessagesBoundary

UserToolSuccessMessage now requires parsedOutput.success before trusting
data, and guards toolResult against non-object values before calling
renderToolResultMessage. String renderedMessage is wrapped in <Text> so
multi-line tool reports (e.g. GoalTool usage report) don't crash Ink.

Defense in depth added to VaultHttpFetchTool/UI (matches the existing
pattern in LocalMemoryRecallTool and GoalTool) and to the
mapToolResultToToolResultBlockParam of both vault/memory tools.

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-26 05:47:52 +08:00
parent 0753dafccc
commit 8246ffa392
6 changed files with 24 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "2.8.1", "version": "2.8.2",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module", "type": "module",
"author": "claude-code-best <claude-code-best@proton.me>", "author": "claude-code-best <claude-code-best@proton.me>",

View File

@@ -137,7 +137,10 @@ export const GoalTool = buildTool({
return `Updating goal: ${input.status}${input.reason ? `${input.reason}` : ''}` return `Updating goal: ${input.status}${input.reason ? `${input.reason}` : ''}`
}, },
renderToolResultMessage(output: Output) { renderToolResultMessage(output: Output) {
if (output.error) return `Goal error: ${output.error}` if (!output) {
return null
}
if (output?.error) return `Goal error: ${output.error}`
if (output.report) return output.report if (output.report) return output.report
if (output.goal) { if (output.goal) {
return `Goal "${output.goal.objective}" — ${output.goal.status}` return `Goal "${output.goal.objective}" — ${output.goal.status}`

View File

@@ -547,7 +547,7 @@ export const LocalMemoryRecallTool = buildTool({
type: 'tool_result', type: 'tool_result',
tool_use_id: toolUseID, tool_use_id: toolUseID,
content: jsonStringify(output), content: jsonStringify(output),
is_error: output.error !== undefined, is_error: output?.error !== undefined,
} }
}, },
} satisfies ToolDef<InputSchema, Output>) } satisfies ToolDef<InputSchema, Output>)

View File

@@ -33,6 +33,9 @@ export function renderToolResultMessage(
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[], _progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
{ verbose }: { verbose: boolean }, { verbose }: { verbose: boolean },
): React.ReactNode { ): React.ReactNode {
// Defense in depth: framework validates via outputSchema, but resumed
// transcripts can still produce null here via deserialization edge cases.
if (!output) return null;
if (output.error) { if (output.error) {
return ( return (
<MessageResponse height={1}> <MessageResponse height={1}>

View File

@@ -409,7 +409,7 @@ export const VaultHttpFetchTool = buildTool({
type: 'tool_result', type: 'tool_result',
tool_use_id: toolUseID, tool_use_id: toolUseID,
content: jsonStringify(output), content: jsonStringify(output),
is_error: output.error !== undefined, is_error: output?.error !== undefined,
} }
}, },
} satisfies ToolDef<InputSchema, Output>) } satisfies ToolDef<InputSchema, Output>)

View File

@@ -67,7 +67,14 @@ export function UserToolSuccessMessage({
if (parsedOutput && !parsedOutput.success) { if (parsedOutput && !parsedOutput.success) {
return null; return null;
} }
const toolResult = parsedOutput?.data ?? message.toolUseResult; // Only trust schema-validated output. Fall back to raw toolUseResult only
// when it's a non-null object — schemas without outputSchema, or successful
// parses that yield null/undefined data, must not reach renderToolResultMessage
// (tool UIs access output.error / output.action on first line and crash).
const toolResult = parsedOutput?.success ? parsedOutput.data : message.toolUseResult;
if (!toolResult || typeof toolResult !== 'object') {
return null;
}
// Collapse diff display for old messages (verbose/ctrl+o overrides) // Collapse diff display for old messages (verbose/ctrl+o overrides)
const effectiveStyle = shouldCollapseDiffs && !verbose ? 'condensed' : style; const effectiveStyle = shouldCollapseDiffs && !verbose ? 'condensed' : style;
@@ -88,6 +95,11 @@ export function UserToolSuccessMessage({
return null; return null;
} }
// Ink requires text strings to be inside <Text>. Tools that return plain
// multi-line strings (e.g. GoalTool's usage report) crash without the wrap.
// React elements from UI.tsx files pass through unchanged.
const wrappedMessage = typeof renderedMessage === 'string' ? <Text>{renderedMessage}</Text> : renderedMessage;
// Tools that return '' from userFacingName opt out of tool chrome and // Tools that return '' from userFacingName opt out of tool chrome and
// render like plain assistant text. Skip the tool-result width constraint // render like plain assistant text. Skip the tool-result width constraint
// so MarkdownTable's SAFETY_MARGIN=4 (tuned for the assistant-text 2-col // so MarkdownTable's SAFETY_MARGIN=4 (tuned for the assistant-text 2-col
@@ -97,7 +109,7 @@ export function UserToolSuccessMessage({
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Box flexDirection="column" width={rendersAsAssistantText ? undefined : width}> <Box flexDirection="column" width={rendersAsAssistantText ? undefined : width}>
{renderedMessage} {wrappedMessage}
{feature('BASH_CLASSIFIER') {feature('BASH_CLASSIFIER')
? classifierRule && ( ? classifierRule && (
<MessageResponse height={1}> <MessageResponse height={1}>