From 3433be18dd82a6e3ac7058ff708faa2a8cd9b764 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 03:39:23 +0800 Subject: [PATCH] fix: Render structured agent fallbacks readably Agent fallback output can arrive as structured result arrays when no terminal task report is available. The desktop card now formats that shape into a readable Markdown list instead of exposing raw transport JSON in the preview or result modal. Constraint: Some Agent executions still only provide structured tool_result content without a terminal Markdown report Rejected: Keep pretty-printed JSON as the fallback | still exposes implementation shape instead of user-facing results Confidence: high Scope-risk: narrow Directive: Structured Agent result arrays are display data; keep them readable before falling back to raw serialization 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 | 54 +++++++++++++++ desktop/src/components/chat/ToolCallGroup.tsx | 67 ++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 75ca1308..f47bfb61 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1134,6 +1134,60 @@ describe('MessageList nested tool calls', () => { expect(within(dialog).queryByText(/raw structured JSON should not be shown/)).toBeNull() }) + it('formats structured agent fallback results as readable markdown', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'tool-agent', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'agent-1', + input: { description: '审查安全风险' }, + timestamp: 1, + }, + { + 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。', + }, + ], + }, + isError: false, + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + expect(screen.getByText(/git:v0\.2\.6\.\.v0\.2\.7:0/)).toBeTruthy() + expect(screen.queryByText(/\{"results"/)).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'View result' })) + + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText('git:v0.2.6..v0.2.7:0')).toBeTruthy() + expect(within(dialog).getByText('/tmp/example/src/lib.rs:220')).toBeTruthy() + expect(within(dialog).getByText(/服务默认监听 0\.0\.0\.0/)).toBeTruthy() + expect(within(dialog).queryByText(/\{"results"/)).toBeNull() + }) + it('renders copy controls for user messages and scopes assistant copy to a single reply', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.assign(navigator, { diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index 3a2671ee..9df1b9a8 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -877,7 +877,72 @@ function getAgentOutputSummary(content: string): string { } function extractAgentDisplayText(content: unknown): string { - return stripAgentResultMetadata(extractTextContent(content)) + return stripAgentResultMetadata(formatAgentStructuredResult(content) || extractTextContent(content)) +} + +function formatAgentStructuredResult(content: unknown): string { + const structured = parseStructuredAgentContent(content) + if (!structured || Array.isArray(structured)) return '' + + const results = structured.results + if (!Array.isArray(results) || results.length === 0) return '' + + const items = results + .map((result, index) => formatAgentStructuredResultItem(result, index)) + .filter(Boolean) + + return items.join('\n') +} + +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 typeof content === 'object' && content !== null ? content as Record | unknown[] : null +} + +function formatAgentStructuredResultItem(result: unknown, index: number): string { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + const text = extractTextContent(result).trim() + return text ? `${index + 1}. ${text}` : '' + } + + const record = result 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 lines = [`${index + 1}. ${location ? formatInlineCode(location) : 'Result'}`] + + if (message) lines.push(` - ${message}`) + if (context) lines.push(` - ${context}`) + if (snippet) lines.push(` - ${snippet}`) + + return lines.join('\n') +} + +function formatAgentResultLocation(record: Record): string { + const file = getStringField(record, 'file') + if (!file) return '' + const line = typeof record.line === 'number' ? record.line : null + return line !== null ? `${file}:${line}` : file +} + +function getStringField(record: Record, key: string): string { + const value = record[key] + return typeof value === 'string' ? value.trim() : '' +} + +function formatInlineCode(value: string): string { + return `\`${value.replace(/`/g, '\\`')}\`` } function stripAgentResultMetadata(text: string): string {