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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 03:39:23 +08:00
parent 430edc436e
commit 3433be18dd
2 changed files with 120 additions and 1 deletions

View File

@ -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(<MessageList />)
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, {

View File

@ -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<string, unknown> | 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<string, unknown> | unknown[] : null
} catch {
return null
}
}
return typeof content === 'object' && content !== null ? content as Record<string, unknown> | 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<string, unknown>
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, unknown>): 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<string, unknown>, 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 {