feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes

- Hoist todo to first-class widget (shadcn checkboxes, brand colors, no
  tool-accordion). Header derives label from active task; non-active rows fade.
- Replace raw JSON dumps with structured key/value summaries via
  formatToolResultSummary; nested error extraction for clearer failures.
- Fix loaded-session grouping: stitch interleaved assistant/tool iterations
  into one bubble instead of orphaned synthetic messages.
- Stable tool/thinking timers via keyed registry so unmount/scroll doesn't
  reset elapsed counts; gate "running" on real live thread state.
- Reorganize chat-only assistant-ui components under components/chat/.
This commit is contained in:
Brooklyn Nicholson
2026-05-11 16:34:25 -04:00
parent 4b3839a8ee
commit 4dd9732a94
34 changed files with 1234 additions and 306 deletions
+104
View File
@@ -26,6 +26,46 @@ describe('toChatMessages', () => {
expect(chatMessageText(messages[0])).toBe('Planning.Done.')
})
it('keeps assistant tool-call iterations in one loaded assistant bubble', () => {
const messages = toChatMessages([
{ role: 'user', content: 'check this repo', timestamp: 1 },
{
role: 'assistant',
content: "Let me also check if there's a top-level lint workflow.",
timestamp: 2,
tool_calls: [{ id: 'tc-1', function: { name: 'search_files', arguments: '{"path":".github"}' } }]
},
{
role: 'tool',
tool_call_id: 'tc-1',
tool_name: 'search_files',
content: '{"error":"Path not found: /repo/.github"}',
timestamp: 3
},
{
role: 'assistant',
content: 'No CI in this repo. Build is enough.',
timestamp: 4,
tool_calls: [{ id: 'tc-2', function: { name: 'terminal', arguments: '{"command":"git status --short"}' } }]
},
{
role: 'tool',
tool_call_id: 'tc-2',
tool_name: 'terminal',
content: '{"output":"M src/ui/components/image-distortion.tsx\\n","exit_code":0}',
timestamp: 5
},
{ role: 'assistant', content: 'Now let me check git status and commit.', timestamp: 6 }
])
const assistantMessages = messages.filter(message => message.role === 'assistant')
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].parts.filter(part => part.type === 'tool-call')).toHaveLength(2)
expect(chatMessageText(assistantMessages[0])).toContain("Let me also check if there's a top-level lint workflow.")
expect(chatMessageText(assistantMessages[0])).toContain('Now let me check git status and commit.')
})
it('hides attached context payloads from user message display', () => {
const [message] = toChatMessages([
{
@@ -120,4 +160,68 @@ describe('upsertToolPart', () => {
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
})
})
it('keeps live todo rows stable across sparse progress payloads', () => {
const first = upsertToolPart(
[],
{
name: 'todo',
todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }],
tool_id: 'todo-1'
},
'running'
)
const progressed = upsertToolPart(
first,
{
name: 'todo',
preview: 'updating plan',
tool_id: 'todo-1'
},
'running'
)
const [part] = progressed
const args = part && 'args' in part ? (part.args as Record<string, unknown>) : {}
expect(args.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
})
it('archives todo state on completion and accepts explicit empty clears', () => {
const started = upsertToolPart(
[],
{
name: 'todo',
todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }],
tool_id: 'todo-1'
},
'running'
)
const completed = upsertToolPart(
started,
{
name: 'todo',
tool_id: 'todo-1'
},
'complete'
)
const cleared = upsertToolPart(
completed,
{
name: 'todo',
todos: [],
tool_id: 'todo-1'
},
'complete'
)
const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {}
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
expect(clearedResult.todos).toEqual([])
})
})
+98 -36
View File
@@ -1,6 +1,7 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import { parseTodos } from '@/lib/todos'
import type { SessionMessage, UsageStats } from '@/types/hermes'
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
@@ -211,21 +212,42 @@ function toolId(payload: GatewayEventPayload | undefined): string {
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
}
function toolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
// Carry todo state across sparse progress payloads: if this todo event lacks
// a `todos` field, fall back to whatever we previously stored on the part.
function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]): { todos: unknown } | undefined {
if (payload && Object.hasOwn(payload, 'todos')) {
const next = parseTodos(payload.todos)
return next === null ? undefined : { todos: next }
}
if (payload?.name !== 'todo') {return undefined}
for (const p of prev) {
const carried = parseTodos(recordFromUnknown(p)?.todos)
if (carried !== null) {return { todos: carried }}
}
return undefined
}
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
return {
...(payload?.context ? { context: payload.context } : {}),
...(payload?.preview ? { preview: payload.preview } : {})
...(payload?.preview ? { preview: payload.preview } : {}),
...carryTodos(payload, prevArgs)
}
}
function toolResult(payload: GatewayEventPayload | undefined): Record<string, unknown> {
function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record<string, unknown> {
return {
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}),
...(payload?.todos ? { todos: payload.todos } : {}),
...carryTodos(payload, prevResult, prevArgs),
...(payload?.error ? { error: payload.error } : {})
}
}
@@ -243,24 +265,21 @@ export function upsertToolPart(
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
)
const prev = index >= 0 ? next[index] : null
const prevArgs = prev && 'args' in prev ? prev.args : undefined
const prevResult = prev && 'result' in prev ? prev.result : undefined
const args = toolArgs(payload, prevArgs)
const base = {
type: 'tool-call' as const,
toolCallId: id,
toolName: name,
args: toolArgs(payload) as never,
argsText: JSON.stringify(toolArgs(payload)),
...(phase === 'complete'
? {
result: toolResult(payload),
isError: Boolean(payload?.error)
}
: {})
args: args as never,
argsText: JSON.stringify(args),
...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) })
} satisfies ChatMessagePart
if (index === -1) {
return [...next, base]
}
if (index === -1) {return [...next, base]}
next[index] = { ...next[index], ...base }
return next
@@ -457,20 +476,48 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
const result: ChatMessage[] = []
let pendingToolParts: ChatMessagePart[] = []
let pendingToolTimestamp: number | undefined
let activeAssistantIndex: null | number = null
const clearPendingTools = () => {
pendingToolParts = []
pendingToolTimestamp = undefined
}
const appendPartsToActiveAssistant = (parts: ChatMessagePart[], timestamp?: number): boolean => {
if (activeAssistantIndex === null) {
return false
}
const active = result[activeAssistantIndex]
if (!active || active.role !== 'assistant') {
activeAssistantIndex = null
return false
}
active.parts = [...active.parts, ...parts]
active.timestamp = timestamp ?? active.timestamp
return true
}
const flushPendingTools = (index: number) => {
if (!pendingToolParts.length) {
return
}
result.push({
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
role: 'assistant',
parts: pendingToolParts,
timestamp: pendingToolTimestamp
})
pendingToolParts = []
pendingToolTimestamp = undefined
if (!appendPartsToActiveAssistant(pendingToolParts, pendingToolTimestamp)) {
result.push({
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
role: 'assistant',
parts: pendingToolParts,
timestamp: pendingToolTimestamp
})
activeAssistantIndex = result.length - 1
}
clearPendingTools()
}
messages.forEach((message, index) => {
@@ -515,6 +562,11 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
}
if (!parts.length) {
if (message.role !== 'assistant') {
flushPendingTools(index)
activeAssistantIndex = null
}
return
}
@@ -528,22 +580,30 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
return
}
if (message.role === 'assistant' && pendingToolParts.length) {
const last = result.at(-1)
if (message.role === 'assistant') {
if (pendingToolParts.length) {
if (!appendPartsToActiveAssistant(pendingToolParts, message.timestamp ?? pendingToolTimestamp)) {
parts.unshift(...pendingToolParts)
}
if (last?.role === 'assistant') {
last.parts = [...last.parts, ...pendingToolParts, ...parts]
last.timestamp = message.timestamp ?? last.timestamp
pendingToolParts = []
pendingToolTimestamp = undefined
clearPendingTools()
}
const activeAssistant =
activeAssistantIndex !== null && result[activeAssistantIndex]?.role === 'assistant'
? result[activeAssistantIndex]
: null
const currentHasToolCall = parts.some(part => part.type === 'tool-call')
const activeHasToolCall = Boolean(activeAssistant?.parts.some(part => part.type === 'tool-call'))
if (activeAssistant && (currentHasToolCall || activeHasToolCall)) {
activeAssistant.parts = [...activeAssistant.parts, ...parts]
activeAssistant.timestamp = message.timestamp ?? activeAssistant.timestamp
return
}
parts.unshift(...pendingToolParts)
pendingToolParts = []
pendingToolTimestamp = undefined
} else if (message.role !== 'assistant') {
} else {
flushPendingTools(index)
}
@@ -553,6 +613,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
parts,
timestamp: message.timestamp
})
activeAssistantIndex = message.role === 'assistant' ? result.length - 1 : null
})
flushPendingTools(messages.length)
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { parseTodos } from './todos'
describe('parseTodos', () => {
it('parses todo arrays with valid ids, content, and statuses', () => {
expect(
parseTodos([
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
{ content: 'Serve', id: 'serve', status: 'pending' }
])
).toEqual([
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
{ content: 'Serve', id: 'serve', status: 'pending' }
])
})
it('parses nested todo payloads from wrapped objects and JSON strings', () => {
expect(parseTodos({ todos: [{ content: 'Plate', id: 'plate', status: 'pending' }] })).toEqual([
{ content: 'Plate', id: 'plate', status: 'pending' }
])
expect(parseTodos('{"todos":[{"id":"plate","content":"Plate","status":"pending"}]}')).toEqual([
{ content: 'Plate', id: 'plate', status: 'pending' }
])
})
it('returns null for non-todo payloads', () => {
expect(parseTodos(undefined)).toBeNull()
expect(parseTodos('not json')).toBeNull()
expect(parseTodos({ message: 'no todos here' })).toBeNull()
})
})
+38
View File
@@ -0,0 +1,38 @@
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'
export interface TodoItem {
content: string
id: string
status: TodoStatus
}
const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'cancelled']
const isRecord = (v: unknown): v is Record<string, unknown> => Boolean(v && typeof v === 'object' && !Array.isArray(v))
const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]).includes(v as string)
function parseArray(value: unknown[]): TodoItem[] {
return value.flatMap(item => {
if (!isRecord(item) || !isStatus(item.status)) {return []}
const id = String(item.id ?? '').trim()
const content = String(item.content ?? '').trim()
return id && content ? [{ content, id, status: item.status }] : []
})
}
function parse(value: unknown, depth: number): null | TodoItem[] {
if (depth > 2) {return null}
if (Array.isArray(value)) {return parseArray(value)}
if (typeof value === 'string' && value.trim()) {
try { return parse(JSON.parse(value), depth + 1) } catch { return null }
}
if (isRecord(value) && Object.hasOwn(value, 'todos')) {return parse(value.todos, depth + 1)}
return null
}
export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0)
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { extractToolErrorMessage, formatToolResultSummary } from './tool-result-summary'
describe('formatToolResultSummary', () => {
it('unwraps wrapper payloads into structured key-value lines', () => {
const summary = formatToolResultSummary({
success: true,
result: {
data: {
path: '/tmp/demo.txt',
status: 'ok',
lines_written: 12,
checksum: 'abc123'
}
}
})
expect(summary).toContain('- Path: /tmp/demo.txt')
expect(summary).toContain('- Status: ok')
expect(summary).toContain('- Lines Written: 12')
expect(summary).not.toContain('"path"')
})
it('summarizes object arrays as readable list items', () => {
const summary = formatToolResultSummary([
{ title: 'First result', snippet: 'alpha preview text' },
{ title: 'Second result', status: 'cached' },
{ title: 'Third result', summary: 'more details' },
{ title: 'Fourth result', summary: 'line 4' },
{ title: 'Fifth result', summary: 'line 5' },
{ title: 'Sixth result', summary: 'line 6' },
{ title: 'Seventh result', summary: 'line 7' }
])
expect(summary).toContain('- First result - alpha preview text')
expect(summary).toContain('- Second result (cached)')
expect(summary).toContain('- … 1 more item')
})
it('truncates long field values for compact display', () => {
const summary = formatToolResultSummary({
message: 'ok',
details: `prefix ${'x'.repeat(500)}`
})
const detailsLine = summary
.split('\n')
.find(line => line.startsWith('- Details:'))
expect(detailsLine).toBeTruthy()
expect(detailsLine?.length).toBeLessThan(230)
expect(detailsLine).toContain('…')
})
it('formats stringified json payloads without raw dumps', () => {
const summary = formatToolResultSummary(
JSON.stringify({
data: {
title: 'Build report',
completed: true
}
})
)
expect(summary).toContain('- Title: Build report')
expect(summary).toContain('- Completed: true')
})
})
describe('extractToolErrorMessage', () => {
it('finds nested error messages through wrappers', () => {
const error = extractToolErrorMessage({
success: false,
result: {
output: {
error: {
message: 'Permission denied writing /tmp/demo.txt'
}
}
}
})
expect(error).toBe('Permission denied writing /tmp/demo.txt')
})
it('does not treat successful payload messages as errors', () => {
const error = extractToolErrorMessage({
success: true,
message: 'Completed successfully',
data: { count: 3 }
})
expect(error).toBe('')
})
it('ignores placeholder error fields in successful payloads', () => {
const error = extractToolErrorMessage({
success: true,
data: {
error: 'none',
status: 'ok'
}
})
expect(error).toBe('')
})
})
+326
View File
@@ -0,0 +1,326 @@
// Heuristic JSON → human summary for tool results. Default view; technical
// mode still gets the raw JSON section.
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
const PRIORITY_KEYS = ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'status', 'id', 'message', 'summary', 'description'] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
type Json = Record<string, unknown>
const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' && !Array.isArray(v))
function tryJson(value: string): unknown {
const t = value.trim()
if (!t) {return ''}
if (!/^[{[]|^"/.test(t)) {return value}
try { return JSON.parse(t) } catch { return value }
}
const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
const titleCase = (k: string) =>
k.split(/[_\-.]+/).filter(Boolean).map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`).join(' ')
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
function clipInline(value: string, max = 180): string {
const c = value.replace(/\s+/g, ' ').trim()
return c.length > max ? `${c.slice(0, max - 1)}` : c
}
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
const t = value.trim()
if (!t) {return ''}
const lines = t.split('\n')
let text = lines.slice(0, maxLines).join('\n')
const clipped = lines.length > maxLines || text.length > maxChars
if (text.length > maxChars) {text = text.slice(0, maxChars - 1).trimEnd()}
return clipped && !text.endsWith('…') ? `${text}` : text
}
function firstString(record: Json, keys: readonly string[]): string {
for (const k of keys) {
const v = record[k]
if (typeof v === 'string' && v.trim()) {return v.trim()}
}
return ''
}
function orderedKeys(keys: string[]): string[] {
const priority = PRIORITY_KEYS.filter(k => keys.includes(k))
const rest = keys.filter(k => !priority.includes(k as never))
return [...priority, ...rest]
}
const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes(k)
const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
function summarizeScalar(v: unknown): string {
if (typeof v === 'string') {return clipInline(v)}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
return ''
}
function summarizeRecordInline(record: Json, depth: number): string {
if (depth > 3) {return pluralize(Object.keys(record).length, 'field')}
const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
const status = firstString(record, ['status', 'category', 'type'])
const message = firstString(record, ['snippet', 'summary', 'description', 'message'])
if (title && status) {return `${clipInline(title, 110)} (${clipInline(status, 54)})`}
if (title && message && title !== message) {return `${clipInline(title, 90)} - ${clipInline(message, 84)}`}
if (title) {return clipInline(title, 150)}
const pairs = orderedKeys(Object.keys(record))
.filter(k => !skipField(k, record[k]))
.map(k => {
const s = summarizeScalar(record[k])
return s ? `${titleCase(k)}: ${s}` : ''
})
.filter(Boolean)
.slice(0, 2)
return pairs.length ? pairs.join(' · ') : pluralize(Object.keys(record).length, 'field')
}
function summarizeListItem(item: unknown, depth: number): string {
const v = norm(item)
if (typeof v === 'string') {return clipInline(v)}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (v == null) {return ''}
if (Array.isArray(v)) {return pluralize(v.length, 'item')}
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
return clipInline(String(v))
}
function formatFieldValue(value: unknown, depth: number): string {
const v = norm(value)
const scalar = summarizeScalar(v)
if (scalar) {return scalar}
if (v == null) {return ''}
if (Array.isArray(v)) {
if (!v.length) {return '0 items'}
const scalars = v.map(summarizeScalar).filter(Boolean)
if (scalars.length === v.length && v.length <= 4) {return clipInline(scalars.join(', '))}
const first = summarizeListItem(v[0], depth + 1)
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
}
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
return clipInline(String(v))
}
function formatArraySummary(value: unknown[], depth: number): string {
if (!value.length) {return 'No items returned.'}
const max = 6
const lines = value.slice(0, max).map(item => summarizeListItem(item, depth + 1)).filter(Boolean).map(l => `- ${l}`)
if (!lines.length) {return `Returned ${pluralize(value.length, 'item')}.`}
if (value.length > max) {
const remaining = value.length - max
lines.push(`- … ${remaining} more ${remaining === 1 ? 'item' : 'items'}`)
}
return lines.join('\n')
}
function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record)
if (!keys.length) {return 'Returned an empty object.'}
if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k))
if (direct && meaningful.length <= 1) {return clipBlock(direct)}
}
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
const max = 8
const lines: string[] = []
for (const k of candidates) {
const v = formatFieldValue(record[k], depth + 1)
if (!v) {continue}
lines.push(`- ${titleCase(k)}: ${v}`)
if (lines.length >= max) {break}
}
if (!lines.length) {return `Returned object with ${pluralize(keys.length, 'field')}.`}
if (candidates.length > lines.length) {
const remaining = candidates.length - lines.length
lines.push(`- … ${remaining} more ${remaining === 1 ? 'field' : 'fields'}`)
}
return lines.join('\n')
}
function formatSummaryValue(value: unknown, depth: number): string {
if (depth > 4) {return ''}
const v = norm(value)
if (typeof v === 'string') {return clipBlock(v)}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (v == null) {return ''}
if (Array.isArray(v)) {return formatArraySummary(v, depth + 1)}
if (isRecord(v)) {return formatRecordSummary(v, depth + 1)}
return clipInline(String(v))
}
function unwrapPayload(value: unknown): unknown {
let cur: unknown = norm(value)
for (let i = 0; i < 4; i += 1) {
if (!isRecord(cur)) {
return cur
}
const record = cur
const key = WRAPPER_KEYS.find(k => record[k] != null)
if (!key) {
return record
}
cur = norm(record[key])
}
return cur
}
function hasMeaningfulErrorValue(value: unknown): boolean {
const v = norm(value)
if (v == null) {return false}
if (typeof v === 'string') {return !NON_ERROR_TEXT.has(v.trim().toLowerCase())}
if (typeof v === 'boolean') {return v}
if (typeof v === 'number') {return v !== 0}
if (Array.isArray(v)) {return v.some(hasMeaningfulErrorValue)}
if (isRecord(v)) {return Object.keys(v).length > 0}
return true
}
function hasErrorSignal(record: Json): boolean {
const status = typeof record.status === 'string' ? record.status : ''
return (
record.success === false ||
record.ok === false ||
/\b(error|failed|failure|fatal|exception)\b/i.test(status) ||
ERROR_KEYS.some(k => hasMeaningfulErrorValue(record[k]))
)
}
function valueErrorText(value: unknown): string {
const v = norm(value)
if (typeof v === 'string') {return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''}
if (Array.isArray(v)) {
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12)
}
if (isRecord(v)) {
const direct = firstString(v, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)}
}
return ''
}
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
if (depth > 5) {return ''}
const v = norm(value)
if (!v || typeof v !== 'object' || seen.has(v)) {return ''}
seen.add(v)
if (Array.isArray(v)) {
for (const item of v) {
const nested = findNestedError(item, depth + 1, seen)
if (nested) {return nested}
}
return ''
}
const record = v as Json
for (const k of ERROR_KEYS) {
if (!hasMeaningfulErrorValue(record[k])) {continue}
const text = valueErrorText(record[k])
if (text) {return text}
}
if (hasErrorSignal(record)) {
const direct = firstString(record, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)}
}
for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
const nested = findNestedError(record[k], depth + 1, seen)
if (nested) {return nested}
}
return ''
}
export function formatToolResultSummary(value: unknown): string {
return formatSummaryValue(unwrapPayload(value), 0) || formatSummaryValue(value, 0)
}
export function extractToolErrorMessage(value: unknown): string {
return findNestedError(value, 0, new Set())
}