From 3ce7b02fc2dfc0c1713bdbf5e291a21994c74b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 04:05:08 +0800 Subject: [PATCH] fix: Normalize text-wrapped agent result JSON Some providers return the final Agent answer as a text block containing structured JSON instead of Markdown. The desktop result fallback now parses that text-wrapped shape and formats plain and grouped result arrays into readable Markdown before display. Constraint: Historical sessions can store Agent tool_result content as [{ type: 'text', text: '{...}' }] rather than a direct object Rejected: Treat provider-specific JSON output as a model bug only | existing transcripts still need readable display Confidence: high Scope-risk: narrow Directive: Agent result display must normalize text-wrapped structured payloads before falling back to raw text Tested: cd desktop && bun run test -- run src/components/chat/MessageList.test.tsx -t "formats structured agent fallback" Tested: cd desktop && bun run test -- run src/components/chat/MessageList.test.tsx -t agent Not-tested: Full desktop gate per request to avoid broad gate/test runs --- .../src/components/chat/MessageList.test.tsx | 40 +++++---- desktop/src/components/chat/ToolCallGroup.tsx | 81 ++++++++++++++++--- 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index f47bfb61..e4ae602b 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1151,21 +1151,31 @@ describe('MessageList nested tool calls', () => { id: 'result-agent', type: 'tool_result', toolUseId: 'agent-1', - content: { - results: [ - { - file: 'git:v0.2.6..v0.2.7', - line: 0, - snippet: 'v0.2.7 tag = a4c92ec7', - context: '版本范围判断:release-notes/v0.2.7.md 明确相比 v0.2.6。', - }, - { - file: '/tmp/example/src/lib.rs', - line: 220, - context: '中风险:服务默认监听 0.0.0.0。', - }, - ], - }, + content: [ + { + type: 'text', + text: JSON.stringify({ + results: [ + { + file: 'git:v0.2.6..v0.2.7', + line: 0, + snippet: 'v0.2.7 tag = a4c92ec7', + context: '版本范围判断:release-notes/v0.2.7.md 明确相比 v0.2.6。', + }, + { + risk: 'medium', + items: [ + { + file: '/tmp/example/src/lib.rs', + line: 220, + context: '中风险:服务默认监听 0.0.0.0。', + }, + ], + }, + ], + }), + }, + ], isError: false, timestamp: 2, }, diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index 9df1b9a8..d35fd613 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -896,17 +896,32 @@ function formatAgentStructuredResult(content: unknown): string { function parseStructuredAgentContent(content: unknown): Record | unknown[] | null { if (typeof content === 'string') { - const trimmed = content.trim() - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null - try { - const parsed = JSON.parse(trimmed) as unknown - return typeof parsed === 'object' && parsed !== null ? parsed as Record | unknown[] : null - } catch { - return null - } + return parseStructuredAgentText(content) } - return typeof content === 'object' && content !== null ? content as Record | unknown[] : null + if (Array.isArray(content)) { + return parseStructuredAgentText(extractTextContent(content)) + } + + if (content && typeof content === 'object') { + if ('results' in content) return content as Record + + const extracted = extractTextContent(content) + return extracted ? parseStructuredAgentText(extracted) : null + } + + return null +} + +function parseStructuredAgentText(text: string): Record | unknown[] | null { + const trimmed = text.trim() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null + try { + const parsed = JSON.parse(trimmed) as unknown + return typeof parsed === 'object' && parsed !== null ? parsed as Record | unknown[] : null + } catch { + return null + } } function formatAgentStructuredResultItem(result: unknown, index: number): string { @@ -920,6 +935,29 @@ function formatAgentStructuredResultItem(result: unknown, index: number): string const context = getStringField(record, 'context') const snippet = getStringField(record, 'snippet') const message = getStringField(record, 'message') || getStringField(record, 'text') || getStringField(record, 'summary') + const nestedItems = Array.isArray(record.items) ? record.items : [] + + if (nestedItems.length > 0) { + const label = getStringField(record, 'risk') || getStringField(record, 'title') || message || 'Grouped results' + const lines = [`${index + 1}. ${formatAgentGroupLabel(label)}`] + if (context) lines.push(` - ${context}`) + if (snippet) lines.push(` - ${snippet}`) + + nestedItems + .map(formatAgentStructuredNestedItem) + .filter(Boolean) + .forEach((item) => { + lines.push( + item + .split('\n') + .map((line, lineIndex) => `${lineIndex === 0 ? ' - ' : ' '}${line}`) + .join('\n'), + ) + }) + + return lines.join('\n') + } + const lines = [`${index + 1}. ${location ? formatInlineCode(location) : 'Result'}`] if (message) lines.push(` - ${message}`) @@ -929,6 +967,31 @@ function formatAgentStructuredResultItem(result: unknown, index: number): string return lines.join('\n') } +function formatAgentStructuredNestedItem(item: unknown): string { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return extractTextContent(item).trim() + } + + const record = item as Record + const location = formatAgentResultLocation(record) + const context = getStringField(record, 'context') + const snippet = getStringField(record, 'snippet') + const message = getStringField(record, 'message') || getStringField(record, 'text') || getStringField(record, 'summary') + const headingParts = [location ? formatInlineCode(location) : '', message].filter(Boolean) + const lines = [headingParts.join(' - ') || 'Result'] + + if (context) lines.push(context) + if (snippet) lines.push(snippet) + + return lines.join('\n') +} + +function formatAgentGroupLabel(label: string): string { + const normalized = label.trim() + if (!normalized) return 'Grouped results' + return normalized.length > 1 ? `${normalized[0].toUpperCase()}${normalized.slice(1)}` : normalized.toUpperCase() +} + function formatAgentResultLocation(record: Record): string { const file = getStringField(record, 'file') if (!file) return ''