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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 04:05:08 +08:00
parent 3433be18dd
commit 3ce7b02fc2
2 changed files with 97 additions and 24 deletions

View File

@ -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,
},

View File

@ -896,17 +896,32 @@ function formatAgentStructuredResult(content: unknown): string {
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 parseStructuredAgentText(content)
}
return typeof content === 'object' && content !== null ? content as Record<string, unknown> | unknown[] : null
if (Array.isArray(content)) {
return parseStructuredAgentText(extractTextContent(content))
}
if (content && typeof content === 'object') {
if ('results' in content) return content as Record<string, unknown>
const extracted = extractTextContent(content)
return extracted ? parseStructuredAgentText(extracted) : null
}
return null
}
function parseStructuredAgentText(text: string): Record<string, unknown> | 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<string, unknown> | 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<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 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, unknown>): string {
const file = getStringField(record, 'file')
if (!file) return ''