fix(desktop): preserve tool results and separate display metadata

Fixes NousResearch/hermes-agent#107267
This commit is contained in:
Xipong
2026-09-10 11:06:01 +00:00
committed by Teknium
parent 9d75f20630
commit 5ebfa79293
15 changed files with 213 additions and 47 deletions
@@ -5,6 +5,7 @@ import { normalizeExternalUrl } from '@/lib/external-link'
import { summarizeShellCommand } from '@/lib/summarize-command'
import { capitalize, firstStringField, normalize } from '@/lib/text'
import { isCardTool, isFileEditTool, isSilentTool } from '@/lib/tool-render-class'
import { toolResultRecord } from '@/lib/tool-result-metadata'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
import {
@@ -694,7 +695,7 @@ function toolErrorText(part: ToolPart, result: Record<string, unknown>): string
function toolStatus(part: ToolPart, resultRecord: Record<string, unknown>): ToolStatus {
if (part.result === undefined) {
return 'running'
return part.completedAt === undefined ? 'running' : part.isError ? 'error' : 'warning'
}
// Explicit success wins over isError / nested-error heuristics. Memory writes
@@ -1418,7 +1419,7 @@ function dynamicTitle(
export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const argsRecord = parseMaybeObject(part.args)
const resultRecord = parseMaybeObject(part.result)
const resultRecord = toolResultRecord(part)
const meta = toolMeta(part.toolName)
const status = toolStatus(part, resultRecord)
// Skip residual error-heuristic text once status is success (stale isError
@@ -1441,7 +1442,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
titlePartsFromAction(baseTitle, part.result === undefined ? meta.pendingAction : undefined)
)
const title = titleParts.title
const unavailable = part.result === undefined && part.completedAt !== undefined
const title = unavailable ? translateNow('assistant.tool.resultUnavailable') : titleParts.title
const titleEnriched = title !== baseTitle
const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord)
@@ -1503,7 +1505,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
status,
subtitle,
title,
titleAction: titleParts.action,
titleAction: unavailable ? undefined : titleParts.action,
tone: meta.tone
}
}
@@ -1,7 +1,10 @@
import type { ToolResultMetadata } from '@/lib/tool-result-metadata'
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'notice' | 'running' | 'success' | 'warning'
export interface ToolPart {
toolResultMetadata?: ToolResultMetadata
args?: unknown
completedAt?: number
isError?: boolean
@@ -45,6 +45,7 @@ import { connectorCalls } from '@/lib/connector-tools'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { toolResultRecord } from '@/lib/tool-result-metadata'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { recordPreviewArtifact } from '@/store/preview-status'
@@ -360,20 +361,30 @@ function ToolEntry({ part }: ToolEntryProps) {
// below and re-running buildToolView (full JSON.stringify of result) on every
// stream delta — the freeze on big `/learn` runs. Re-derive a stable part from
// the referentially-stable args/result so the memos hold across deltas.
const { args, completedAt, isError, result, timestamp, toolCallId, toolName } = part
const { args, completedAt, isError, result, toolResultMetadata, timestamp, toolCallId, toolName } = part
const stablePart = useMemo<ToolPart>(
() => ({ args, completedAt, isError, result, timestamp, toolCallId, toolName, type: 'tool-call' }),
[args, completedAt, isError, result, timestamp, toolCallId, toolName]
() => ({
args,
completedAt,
isError,
result,
toolResultMetadata,
timestamp,
toolCallId,
toolName,
type: 'tool-call'
}),
[args, completedAt, isError, result, toolResultMetadata, timestamp, toolCallId, toolName]
)
const disclosureId = toolEntryDisclosureId(messageId, stablePart)
const dismissed = useStore($toolRowDismissed(disclosureId))
const isPending = messageRunning && result === undefined
const isPending = messageRunning && result === undefined && completedAt === undefined
// Subscribe to this tool's diff only, so a live patch for one tool doesn't
// re-render every mounted tool row (the factory caches a per-id atom).
const sideDiff = useStore($toolInlineDiff(toolCallId ?? ''))
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(toolResultRecord(stablePart))
const isFileEdit = isFileEditTool(toolName)
const defaultOpen = Boolean(inlineDiff)
const open = useDisclosureOpen(disclosureId, defaultOpen)
@@ -385,11 +396,11 @@ function ToolEntry({ part }: ToolEntryProps) {
const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
// Stale parts (no result, but message stopped running) get a synthetic empty
// result so buildToolView treats them as completed-no-output. Keyed on
// stablePart so it recomputes only when this tool's data changes.
// A stopped turn is not evidence that an unobserved tool succeeded. Use a
// presentation-only completion marker, never manufacture a result.
const view = useMemo(() => {
const p = !isPending && result === undefined ? { ...stablePart, result: {} } : stablePart
const p =
!isPending && result === undefined ? { ...stablePart, completedAt: stablePart.completedAt ?? 0 } : stablePart
return buildToolView(p, inlineDiff)
}, [inlineDiff, isPending, result, stablePart])
@@ -895,7 +906,9 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
: Math.min(earliest, tool.timestamp),
undefined
),
pendingApprovalTool: tools.some(tool => tool.result === undefined && APPROVAL_TOOLS.has(tool.toolName)),
pendingApprovalTool: timelineTools.some(
tool => tool.result === undefined && tool.completedAt === undefined && APPROVAL_TOOLS.has(tool.toolName)
),
summary: summarizeToolRun(tools, live)
}
}
@@ -1030,7 +1043,8 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
* its return type and the underlying ToolEntry stays mounted across
* group-shape changes.
*/
type TimelineToolCallProps = ToolCallMessagePartProps & { completedAt?: number; timestamp?: number }
type TimelineToolCallProps = ToolCallMessagePartProps &
Pick<ToolPart, 'completedAt' | 'timestamp' | 'toolResultMetadata'>
export const ToolFallback = ({
toolCallId,
@@ -1039,9 +1053,20 @@ export const ToolFallback = ({
completedAt,
isError,
result,
toolResultMetadata,
timestamp
}: TimelineToolCallProps) => {
const part: ToolPart = { args, completedAt, isError, result, timestamp, toolCallId, toolName, type: 'tool-call' }
const part: ToolPart = {
args,
completedAt,
isError,
result,
toolResultMetadata,
timestamp,
toolCallId,
toolName,
type: 'tool-call'
}
return <ToolEntry part={part} />
}
+1
View File
@@ -2912,6 +2912,7 @@ export const ar = defineLocale({
statusError: 'خطأ',
statusRecovered: 'تم الاسترداد',
statusDone: 'تم',
resultUnavailable: 'النتيجة غير متاحة',
memoryWriteNoted: 'تم تسجيل كتابة الذاكرة',
actions: {
read: 'قراءة',
+1
View File
@@ -3975,6 +3975,7 @@ export const en: Translations = {
statusError: 'Error',
statusRecovered: 'Recovered',
statusDone: 'Done',
resultUnavailable: 'Result unavailable',
memoryWriteNoted: 'Memory write noted',
actions: {
read: 'Read',
+1
View File
@@ -3370,6 +3370,7 @@ export const ja = defineLocale({
statusError: 'エラー',
statusRecovered: '回復しました',
statusDone: '完了',
resultUnavailable: '結果を取得できません',
memoryWriteNoted: 'メモリへの書き込みを記録',
actions: {
read: '読み取り完了',
+1
View File
@@ -3673,6 +3673,7 @@ export const ru = defineLocale({
statusError: 'Ошибка',
statusRecovered: 'Восстановлено',
statusDone: 'Готово',
resultUnavailable: 'Результат недоступен',
memoryWriteNoted: 'Запись в память отмечена',
actions: {
read: 'Чтение',
+1
View File
@@ -3502,6 +3502,7 @@ export interface Translations {
statusRecovered: string
statusDone: string
/** Over-budget / rejected memory write title — not "Saved to memory". */
resultUnavailable: string
memoryWriteNoted: string
actions: {
read: string
+1
View File
@@ -3323,6 +3323,7 @@ export const zhHant = defineLocale({
statusError: '錯誤',
statusRecovered: '已復原',
statusDone: '完成',
resultUnavailable: '結果無法使用',
memoryWriteNoted: '已記下記憶寫入',
actions: {
read: '已讀取',
+1
View File
@@ -4129,6 +4129,7 @@ export const zh = defineLocale({
statusError: '错误',
statusRecovered: '已恢复',
statusDone: '完成',
resultUnavailable: '结果不可用',
memoryWriteNoted: '已记下记忆写入',
actions: {
read: '已读取',
+9 -18
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { toolResultRecord } from '@/lib/tool-result-metadata'
import type { SessionMessage } from '@/types/hermes'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
@@ -766,7 +767,7 @@ describe('upsertToolPart', () => {
const [part] = parts
expect(part?.type).toBe('tool-call')
expect(part && 'result' in part ? part.result : undefined).toMatchObject({
expect(part && 'result' in part ? toolResultRecord(part) : undefined).toMatchObject({
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
})
})
@@ -828,10 +829,9 @@ describe('upsertToolPart', () => {
'complete'
)
const completedResult =
completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
const completedResult = completed[0] && 'result' in completed[0] ? toolResultRecord(completed[0]) : {}
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {}
const clearedResult = cleared[0] && 'result' in cleared[0] ? toolResultRecord(cleared[0]) : {}
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
expect(clearedResult.todos).toEqual([])
@@ -885,13 +885,7 @@ describe('upsertToolPart', () => {
const contexts = webParts.map(part => String((part.args as Record<string, unknown>)?.context || ''))
const summaries = webParts.map(part => {
if (!('result' in part) || !part.result || typeof part.result !== 'object') {
return ''
}
return String((part.result as Record<string, unknown>).summary || '')
})
const summaries = webParts.map(part => String(toolResultRecord(part).summary || ''))
expect(webParts).toHaveLength(2)
expect(contexts).toEqual(['tokyo weather', 'reykjavik weather'])
@@ -957,7 +951,7 @@ describe('upsertToolPart', () => {
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({
context: 'auckland weather today and tomorrow forecast'
})
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
expect(toolResultRecord(part as Extract<ChatMessagePart, { type: 'tool-call' }>)).toMatchObject({
summary: 'Did 5 searches in 1.1s'
})
})
@@ -1026,7 +1020,7 @@ describe('upsertToolPart', () => {
expect(webParts).toHaveLength(1)
expect(webParts[0].toolCallId).toBe('search-asuncion')
expect(webParts[0].result).toMatchObject({ summary: 'Did 5 searches in 1.1s' })
expect(toolResultRecord(webParts[0])).toMatchObject({ summary: 'Did 5 searches in 1.1s' })
})
it('matches id-less live starts with later identified progress updates', () => {
@@ -1125,10 +1119,7 @@ describe('upsertToolPart', () => {
.map(part => ({
id: part.toolCallId,
query: String((part.args as Record<string, unknown>)?.query || ''),
summary:
part.result && typeof part.result === 'object'
? String((part.result as Record<string, unknown>).summary || '')
: ''
summary: String(toolResultRecord(part).summary || '')
}))
expect(webParts).toEqual([
@@ -1172,7 +1163,7 @@ describe('upsertToolPart', () => {
const [part] = completed
expect(part?.type).toBe('tool-call')
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
expect(toolResultRecord(part as Extract<ChatMessagePart, { type: 'tool-call' }>)).toMatchObject({
data: { web: [{ title: 'Suva forecast' }] },
summary: 'Did 1 search in 0.5s'
})
@@ -1,5 +1,6 @@
import { firstStringField, normalize } from '@/lib/text'
import { isTodoToolName, parseTodos } from '@/lib/todos'
import type { ToolResultMetadata } from '@/lib/tool-result-metadata'
import type { SessionMessage } from '@/types/hermes'
import type { ChatMessage, ChatMessagePart, GatewayEventPayload } from './types'
@@ -184,7 +185,18 @@ function findToolPartIndex(
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index]
if (part.type === 'tool-call' && part.toolName === name && part.result === undefined) {
if (
part.type === 'tool-call' &&
part.toolName === name &&
part.result === undefined &&
part.completedAt === undefined
) {
// A new identified start cannot replace a different identified call.
// Only id-less placeholders may acquire an ID on a later start event.
if (stableId && phase === 'running' && part.toolCallId && !part.toolCallId.startsWith('live-tool:')) {
continue
}
pendingIndices.push(index)
}
}
@@ -264,22 +276,21 @@ function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown):
}
}
function toolResult(
function toolResultMetadata(
payload: GatewayEventPayload | undefined,
previous: ToolResultMetadata | undefined,
prevResult?: unknown,
prevArgs?: unknown
): Record<string, unknown> {
const parsedResult = parseMaybeJsonObject(payload?.result)
): ToolResultMetadata {
return {
...parsedResult,
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
...previous,
...(payload?.inline_diff !== undefined ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary !== undefined ? { summary: payload.summary } : {}),
...(payload?.message !== undefined ? { message: payload.message } : {}),
...(payload?.preview !== undefined ? { preview: payload.preview } : {}),
...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}),
...carryTodos(payload, prevResult, prevArgs),
...(payload?.error ? { error: payload.error } : {})
...(payload?.error !== undefined ? { error: payload.error } : {})
}
}
@@ -330,8 +341,10 @@ export function upsertToolPart(
timestamp: prev?.timestamp ?? occurredAt,
...(phase === 'complete' && {
completedAt: occurredAt,
result: toolResult(payload, prevResult, prevArgs),
isError: Boolean(payload?.error)
result: payload?.result !== undefined ? payload.result : prevResult,
toolResultMetadata: toolResultMetadata(payload, prev?.toolResultMetadata, prevResult, prevArgs),
isError:
payload?.error !== undefined ? Boolean(payload.error) : Boolean(prev && 'isError' in prev && prev.isError)
})
} satisfies ChatMessagePart
@@ -0,0 +1,89 @@
import { fromThreadMessageLike, getAutoStatus } from '@assistant-ui/core/internal'
import { describe, expect, it } from 'vitest'
import { buildToolView } from '@/components/assistant-ui/tool/fallback-model'
import { toRuntimeMessage } from '@/lib/chat-runtime'
import { upsertToolPart } from './tool-parts'
import type { ChatMessagePart } from './types'
describe('live tool result evidence', () => {
it('preserves every JSON value and display hints through the real runtime adapter and replay', () => {
const values = [
'plain text\n',
'',
'{"answer":1}',
'[1,false]',
0,
false,
null,
[],
[1, false],
{ summary: 'original', output: 'ok' }
]
for (const [index, result] of values.entries()) {
const tool_id = `call-${index}`
const parts = upsertToolPart(
[],
{ name: 'terminal', tool_id, result, summary: 'abbreviated', duration_s: 0 },
'complete',
2
)
const [part] = parts
expect(part.type).toBe('tool-call')
if (part.type !== 'tool-call') {
throw new Error('Missing tool call')
}
expect(part.result).toBe(result)
expect(part.toolResultMetadata).toMatchObject({ summary: 'abbreviated', duration_s: 0 })
const replayed = upsertToolPart(parts, { name: 'terminal', tool_id, message: 'completed' }, 'complete', 3)
expect((replayed[0] as typeof part).result).toBe(result)
const runtime = fromThreadMessageLike(
toRuntimeMessage({ id: tool_id, role: 'assistant', parts: replayed }),
tool_id,
getAutoStatus(false, false, false, false, undefined)
)
const received = runtime.content[0] as typeof part
expect(received.result).toEqual(result)
expect(received.toolResultMetadata).toMatchObject({ summary: 'abbreviated', message: 'completed' })
if (typeof result === 'object' && result && 'summary' in result) {
expect((received.result as typeof result).summary).toBe(result.summary)
}
}
})
it('distinguishes a missing completion result from empty results and keeps parallel completions separate', () => {
let parts: ChatMessagePart[] = []
for (const [tool_id, command] of [
['a', 'echo a'],
['b', 'echo b']
]) {
parts = upsertToolPart(parts, { name: 'terminal', tool_id, args: { command } }, 'running', 1)
}
parts = upsertToolPart(parts, { name: 'terminal', tool_id: 'b', result: '' }, 'complete', 2)
parts = upsertToolPart(parts, { name: 'terminal', tool_id: 'a', summary: 'done' }, 'complete', 3)
expect(parts).toHaveLength(2)
const [missing, empty] = parts
if (missing.type !== 'tool-call' || empty.type !== 'tool-call') {
throw new Error('Missing tool call')
}
expect(missing.result).toBeUndefined()
expect(missing.completedAt).toBe(3)
expect(buildToolView(missing, '').status).toBe('warning')
expect(empty.result).toBe('')
expect(buildToolView(empty, '').status).toBe('success')
})
})
@@ -2,9 +2,11 @@ import type { ThreadMessageLike } from '@assistant-ui/react'
import { type BillingBlock } from '@hermes/shared'
import type { ErrorSurface } from '@/lib/error-surface'
import type { ToolResultMetadata } from '@/lib/tool-result-metadata'
import type { MessageReaction, SessionMessage, UsageStats } from '@/types/hermes'
export interface TimelinePartMetadata {
toolResultMetadata?: ToolResultMetadata
/** Unix seconds when this visible activity segment began. Fractional values
* preserve the millisecond precision available on live gateway events. */
timestamp?: number
@@ -0,0 +1,34 @@
/** Gateway display hints are not the tool's result. Keep them off the raw
* payload so summaries cannot overwrite evidence, including false/null/"". */
export interface ToolResultMetadata {
duration_s?: number
error?: string | boolean
inline_diff?: string
message?: string
preview?: string
summary?: string
todos?: unknown
}
export interface ToolResultSource {
result?: unknown
toolResultMetadata?: ToolResultMetadata
}
/** A derived record for existing presentation code; never stored as the result. */
export function toolResultRecord(source: ToolResultSource): Record<string, unknown> {
let value = source.result
if (typeof value === 'string') {
try {
value = JSON.parse(value)
} catch {
value = undefined
}
}
const record = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
// Authoritative result fields win over fallible/abbreviated display hints.
return { ...source.toolResultMetadata, ...record }
}