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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user