diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 9e569a6d41..3bd3141e41 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -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 function toolStatus(part: ToolPart, resultRecord: Record): 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 } } diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts index 9aa5b8dca7..3fde8136d5 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts @@ -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 diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 527d97206e..6c9112e9ba 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -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( - () => ({ 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 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 } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index de746c054a..c8702f0a00 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2912,6 +2912,7 @@ export const ar = defineLocale({ statusError: 'خطأ', statusRecovered: 'تم الاسترداد', statusDone: 'تم', + resultUnavailable: 'النتيجة غير متاحة', memoryWriteNoted: 'تم تسجيل كتابة الذاكرة', actions: { read: 'قراءة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1081677745..48323d57f3 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -3975,6 +3975,7 @@ export const en: Translations = { statusError: 'Error', statusRecovered: 'Recovered', statusDone: 'Done', + resultUnavailable: 'Result unavailable', memoryWriteNoted: 'Memory write noted', actions: { read: 'Read', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 0d9c63d7d0..f725cb9b98 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -3370,6 +3370,7 @@ export const ja = defineLocale({ statusError: 'エラー', statusRecovered: '回復しました', statusDone: '完了', + resultUnavailable: '結果を取得できません', memoryWriteNoted: 'メモリへの書き込みを記録', actions: { read: '読み取り完了', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 8a1918268c..663e4dd7ab 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -3673,6 +3673,7 @@ export const ru = defineLocale({ statusError: 'Ошибка', statusRecovered: 'Восстановлено', statusDone: 'Готово', + resultUnavailable: 'Результат недоступен', memoryWriteNoted: 'Запись в память отмечена', actions: { read: 'Чтение', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b8be7faeae..38bf01eb15 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 33652efd81..5311161ed8 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -3323,6 +3323,7 @@ export const zhHant = defineLocale({ statusError: '錯誤', statusRecovered: '已復原', statusDone: '完成', + resultUnavailable: '結果無法使用', memoryWriteNoted: '已記下記憶寫入', actions: { read: '已讀取', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 7646bacee6..f5d17844cc 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -4129,6 +4129,7 @@ export const zh = defineLocale({ statusError: '错误', statusRecovered: '已恢复', statusDone: '完成', + resultUnavailable: '结果不可用', memoryWriteNoted: '已记下记忆写入', actions: { read: '已读取', diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 0874c2a6c6..5d67d31517 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -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) : {} + const completedResult = completed[0] && 'result' in completed[0] ? toolResultRecord(completed[0]) : {} - const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record) : {} + 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)?.context || '')) - const summaries = webParts.map(part => { - if (!('result' in part) || !part.result || typeof part.result !== 'object') { - return '' - } - - return String((part.result as Record).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).args).toMatchObject({ context: 'auckland weather today and tomorrow forecast' }) - expect((part as Extract).result).toMatchObject({ + expect(toolResultRecord(part as Extract)).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)?.query || ''), - summary: - part.result && typeof part.result === 'object' - ? String((part.result as Record).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).result).toMatchObject({ + expect(toolResultRecord(part as Extract)).toMatchObject({ data: { web: [{ title: 'Suva forecast' }] }, summary: 'Did 1 search in 0.5s' }) diff --git a/apps/desktop/src/lib/chat-messages/tool-parts.ts b/apps/desktop/src/lib/chat-messages/tool-parts.ts index 392ec58b4f..366ccaeeea 100644 --- a/apps/desktop/src/lib/chat-messages/tool-parts.ts +++ b/apps/desktop/src/lib/chat-messages/tool-parts.ts @@ -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 { - 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 diff --git a/apps/desktop/src/lib/chat-messages/tool-result-preservation.test.ts b/apps/desktop/src/lib/chat-messages/tool-result-preservation.test.ts new file mode 100644 index 0000000000..ce9f6a4a09 --- /dev/null +++ b/apps/desktop/src/lib/chat-messages/tool-result-preservation.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/lib/chat-messages/types.ts b/apps/desktop/src/lib/chat-messages/types.ts index d29f7dab1c..1cf7d325dd 100644 --- a/apps/desktop/src/lib/chat-messages/types.ts +++ b/apps/desktop/src/lib/chat-messages/types.ts @@ -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 diff --git a/apps/desktop/src/lib/tool-result-metadata.ts b/apps/desktop/src/lib/tool-result-metadata.ts new file mode 100644 index 0000000000..bace491003 --- /dev/null +++ b/apps/desktop/src/lib/tool-result-metadata.ts @@ -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 { + 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 } +}