fix(desktop): preserve tool results and separate display metadata
Fixes NousResearch/hermes-agent#107267
This commit is contained in:
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -2912,6 +2912,7 @@ export const ar = defineLocale({
|
||||
statusError: 'خطأ',
|
||||
statusRecovered: 'تم الاسترداد',
|
||||
statusDone: 'تم',
|
||||
resultUnavailable: 'النتيجة غير متاحة',
|
||||
memoryWriteNoted: 'تم تسجيل كتابة الذاكرة',
|
||||
actions: {
|
||||
read: 'قراءة',
|
||||
|
||||
@@ -3975,6 +3975,7 @@ export const en: Translations = {
|
||||
statusError: 'Error',
|
||||
statusRecovered: 'Recovered',
|
||||
statusDone: 'Done',
|
||||
resultUnavailable: 'Result unavailable',
|
||||
memoryWriteNoted: 'Memory write noted',
|
||||
actions: {
|
||||
read: 'Read',
|
||||
|
||||
@@ -3370,6 +3370,7 @@ export const ja = defineLocale({
|
||||
statusError: 'エラー',
|
||||
statusRecovered: '回復しました',
|
||||
statusDone: '完了',
|
||||
resultUnavailable: '結果を取得できません',
|
||||
memoryWriteNoted: 'メモリへの書き込みを記録',
|
||||
actions: {
|
||||
read: '読み取り完了',
|
||||
|
||||
@@ -3673,6 +3673,7 @@ export const ru = defineLocale({
|
||||
statusError: 'Ошибка',
|
||||
statusRecovered: 'Восстановлено',
|
||||
statusDone: 'Готово',
|
||||
resultUnavailable: 'Результат недоступен',
|
||||
memoryWriteNoted: 'Запись в память отмечена',
|
||||
actions: {
|
||||
read: 'Чтение',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3323,6 +3323,7 @@ export const zhHant = defineLocale({
|
||||
statusError: '錯誤',
|
||||
statusRecovered: '已復原',
|
||||
statusDone: '完成',
|
||||
resultUnavailable: '結果無法使用',
|
||||
memoryWriteNoted: '已記下記憶寫入',
|
||||
actions: {
|
||||
read: '已讀取',
|
||||
|
||||
@@ -4129,6 +4129,7 @@ export const zh = defineLocale({
|
||||
statusError: '错误',
|
||||
statusRecovered: '已恢复',
|
||||
statusDone: '完成',
|
||||
resultUnavailable: '结果不可用',
|
||||
memoryWriteNoted: '已记下记忆写入',
|
||||
actions: {
|
||||
read: '已读取',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user