fix(chat): harden tool output rendering against non-string fields (#2011) (#2071)

React error #31 (Objects are not valid as a React child) was thrown
intermittently when a task/subagent tool returned structured data
(e.g. { TODO: '...' }) in a field that the OpenCode SDK types as a
plain string. Pathological payloads would propagate into JSX children
without runtime validation, white-screening the chat until refresh.

This change adds a single `coerceToText` helper in toolRenderers.tsx
and applies it at every vulnerable JSX expression:

- ToolPart.tsx:1807,1975  {state.error}     (typed string, can be object)
- ToolPart.tsx:1825       {q.question}      (QuestionCard input cast)
- ToolPart.tsx:1830       {opt.label}       (QuestionCard input cast)
- ToolPart.tsx:1848       task tool markdown output
- ToolPart.tsx:1898       ToolScrollableTextOutput entry
- toolRenderers.tsx       {todo.content}    x4 in renderTodoOutput

renderTodoOutput now also validates the parsed array at the boundary
(JSON.parse result is filtered to objects whose content and status are
runtime strings), so a single bad row no longer poisons the whole
tool output.

Tests: 12 new unit tests in
packages/ui/src/components/chat/message/parts/__tests__/issue-2011-react-error-31.test.ts
covering the {TODO}-key object path, circular references, and
non-string content/status on parsed todos.

Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
This commit is contained in:
Leonid
2026-07-11 14:42:40 +03:00
committed by GitHub
co-authored by bashrusakh
parent d8a904954b
commit f6326e1c35
3 changed files with 136 additions and 14 deletions
@@ -11,6 +11,17 @@ const cleanOutput = (output: string) => {
return cleaned.trim();
};
export const coerceToText = (value: unknown, fallback = ''): string => {
if (typeof value === 'string') return value;
if (value === null || value === undefined) return fallback;
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
try {
return JSON.stringify(value);
} catch {
return fallback;
}
};
const hasLspDiagnostics = (output: string): boolean => {
if (!output) return false;
return output.includes('<diagnostics')
@@ -387,8 +398,18 @@ export const renderTodoOutput = (
options?: { unstyled?: boolean },
) => {
try {
const todos = JSON.parse(output) as Todo[];
if (!Array.isArray(todos)) {
const raw: unknown = JSON.parse(output);
if (!Array.isArray(raw)) {
return null;
}
const todos: Todo[] = raw.filter(
(t): t is Todo =>
!!t &&
typeof t === 'object' &&
typeof (t as { content?: unknown }).content === 'string' &&
typeof (t as { status?: unknown }).status === 'string',
);
if (todos.length === 0) {
return null;
}
@@ -446,7 +467,7 @@ export const renderTodoOutput = (
{todosByStatus.in_progress.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
{getPriorityDot(todo.priority)}
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
<span className="typography-code text-foreground flex-1 leading-relaxed">{coerceToText(todo.content)}</span>
</div>
))}
</div>
@@ -463,7 +484,7 @@ export const renderTodoOutput = (
{todosByStatus.pending.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
{getPriorityDot(todo.priority)}
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
<span className="typography-code text-foreground flex-1 leading-relaxed">{coerceToText(todo.content)}</span>
</div>
))}
</div>
@@ -480,7 +501,7 @@ export const renderTodoOutput = (
{todosByStatus.completed.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
<Icon name="check" className="w-3 h-3 mt-0.5 flex-shrink-0" style={{ color: 'var(--status-success)', opacity: 0.7 }}/>
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
<span className="typography-code text-foreground flex-1 leading-relaxed">{coerceToText(todo.content)}</span>
</div>
))}
</div>
@@ -497,7 +518,7 @@ export const renderTodoOutput = (
{todosByStatus.cancelled.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
<span className="w-3 h-3 text-muted-foreground/50 mt-0.5 flex-shrink-0">×</span>
<span className="typography-code text-muted-foreground/50 line-through flex-1 leading-relaxed">{todo.content}</span>
<span className="typography-code text-muted-foreground/50 line-through flex-1 leading-relaxed">{coerceToText(todo.content)}</span>
</div>
))}
</div>