fix(desktop): keep live tool activity inspectable and name skill loads

Fixes NousResearch/hermes-agent#107268
This commit is contained in:
Xipong
2026-09-10 11:05:33 +00:00
committed by Teknium
parent 5ebfa79293
commit 2e786d901b
13 changed files with 324 additions and 35 deletions
@@ -8,6 +8,8 @@ import { isCardTool, isFileEditTool, isSilentTool } from '@/lib/tool-render-clas
import { toolResultRecord } from '@/lib/tool-result-metadata'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
import { skillActivityTitle } from '../skill-activity'
import {
browserExecStepLabel,
compactPreview,
@@ -1310,6 +1312,12 @@ function dynamicTitle(
result: Record<string, unknown>,
fallback: ToolTitleParts
): ToolTitleParts {
const skillTitle = skillActivityTitle(part)
if (skillTitle) {
return { title: skillTitle }
}
const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past)
const titledAction = (action: string, title: string): ToolTitleParts =>
@@ -82,6 +82,7 @@ import { ToolRunTicker } from './run-ticker'
// false, so every row currently owns its own chrome; kept as a seam for any
// future embedding surface.
const ToolEmbedContext = createContext(false)
const ToolRunDisclosureContext = createContext<string | null>(null)
// A search hit's title is result *content* inside an expanded row, not one of
// the scaffolding lines, so it keeps the brighter secondary grey.
@@ -355,6 +356,7 @@ function ToolEntry({ part }: ToolEntryProps) {
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const embedded = useContext(ToolEmbedContext)
const runDisclosureId = useContext(ToolRunDisclosureContext)
const toolViewMode = useStore($toolViewMode)
// `ToolFallback` rebuilds the `part` wrapper each render, defeating the memos
@@ -565,7 +567,18 @@ function ToolEntry({ part }: ToolEntryProps) {
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
<DisclosureRow
action={dismissAction}
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
onToggle={
hasExpandableContent
? () => {
// Opening a row is newer intent than an earlier group collapse.
if (!open && runDisclosureId) {
setToolDisclosureOpen(runDisclosureId, true)
}
setToolDisclosureOpen(disclosureId, !open)
}
: undefined
}
open={open}
trailing={trailing}
>
@@ -806,7 +819,7 @@ export function splitRunItems(toolNames: readonly string[]): RunItem[] {
*/
// The one grey line that stands in for a run of tool calls — "Explored 3
// files, ran 5 commands". Live, it narrates in the present tense above the
// ticker and offers no toggle, since there is nothing settled to unfold yet.
// ticker by default; its toggle can reveal the activity before it settles.
function ToolRunHeader({
completedAt,
live,
@@ -855,7 +868,8 @@ interface ToolRunState {
// re-render the group on every text delta in the turn. The run only changes
// when a call arrives or one finishes; cache on exactly that.
function useToolRun(startIndex: number, endIndex: number): ToolRunState {
const cache = useRef<null | { signature: string; value: ToolRunState }>(null)
const { locale } = useI18n()
const cache = useRef<null | { signature: string; tools: readonly ToolPart[]; value: ToolRunState }>(null)
return useAuiState(state => {
const parts = state.message.parts
@@ -877,12 +891,28 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
tool =>
`${tool.toolCallId}:${tool.result === undefined ? 0 : 1}:${tool.timestamp ?? ''}:${tool.completedAt ?? ''}`
)
.concat(String(live))
.concat(String(live), state.message.id, locale)
.join('|')
if (cache.current?.signature !== signature) {
// The arguments may arrive after tool.start. Compare references rather
// than stringify potentially huge args/results on every streaming tick.
const sameInputs =
cache.current?.tools.length === timelineTools.length &&
timelineTools.every((tool, index) => {
const previous = cache.current!.tools[index]
return (
tool.args === previous.args &&
tool.result === previous.result &&
tool.toolName === previous.toolName &&
tool.isError === previous.isError
)
})
if (cache.current?.signature !== signature || !sameInputs) {
cache.current = {
signature,
tools: timelineTools,
value: {
completedAt: timelineTools.reduce<number | undefined>(
(latest, tool) =>
@@ -895,7 +925,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
),
count: tools.length,
entryIds: tools.map(tool => toolEntryDisclosureId(state.message.id, tool)),
key: tools[0]?.toolCallId ?? '',
key: `${state.message.id}:${tools[0]?.toolCallId ?? ''}`,
live,
startedAt: timelineTools.reduce<number | undefined>(
(earliest, tool) =>
@@ -954,7 +984,7 @@ const ToolRun: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> =
// A lone call is already its own one-line summary; heading it with a second
// line would say the same thing twice.
if (count < 2) {
return <>{children}</>
return <ToolRunDisclosureContext.Provider value={disclosureId}>{children}</ToolRunDisclosureContext.Provider>
}
// Two things a one-line window can't hold. An approval is a question the
@@ -963,27 +993,28 @@ const ToolRun: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> =
// keeps going. Either one hands the run back its full height until the run
// settles and the row can be reached through the summary instead.
const blocked = Boolean(approval) && pendingApprovalTool
const unfurled = blocked || rowOpen
const expanded = live ? unfurled : (persistedOpen ?? false)
const expanded = blocked || (persistedOpen ?? rowOpen)
return (
<div
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
data-slot="tool-block"
data-tool-group=""
ref={enterRef}
>
<ToolRunHeader
completedAt={completedAt}
live={live}
onToggle={live ? undefined : () => setToolDisclosureOpen(disclosureId, !expanded)}
open={expanded}
startedAt={startedAt}
summary={summary}
/>
{live && !unfurled && <ToolRunTicker>{children}</ToolRunTicker>}
{expanded && <div className="grid min-w-0 max-w-full gap-(--tool-row-gap)">{children}</div>}
</div>
<ToolRunDisclosureContext.Provider value={disclosureId}>
<div
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
data-slot="tool-block"
data-tool-group=""
ref={enterRef}
>
<ToolRunHeader
completedAt={completedAt}
live={live}
onToggle={blocked ? undefined : () => setToolDisclosureOpen(disclosureId, !expanded)}
open={expanded}
startedAt={startedAt}
summary={summary}
/>
{live && !expanded && <ToolRunTicker>{children}</ToolRunTicker>}
{expanded && <div className="grid min-w-0 max-w-full gap-(--tool-row-gap)">{children}</div>}
</div>
</ToolRunDisclosureContext.Provider>
)
}
@@ -1,7 +1,10 @@
import { translateNow } from '@/i18n'
import { summarizeShellCommand } from '@/lib/summarize-command'
import { firstStringField } from '@/lib/text'
import { extractToolErrorMessage } from '@/lib/tool-result-summary'
import { fileEditBasename, isFileEditTool, parseMaybeObject } from './fallback-model'
import { skillActivityTitle } from './skill-activity'
/**
* The little a summary needs from a tool call, stated structurally so both
@@ -10,6 +13,8 @@ import { fileEditBasename, isFileEditTool, parseMaybeObject } from './fallback-m
*/
export interface ToolCallLike {
args?: unknown
completedAt?: number
isError?: boolean
result?: unknown
toolCallId?: string
toolName: string
@@ -64,7 +69,7 @@ function toolCategory(toolName: string): RunCategory {
}
function isPending(tool: ToolCallLike): boolean {
return tool.result === undefined
return tool.result === undefined && tool.completedAt === undefined
}
/**
@@ -73,6 +78,10 @@ function isPending(tool: ToolCallLike): boolean {
* described in the same words from the moment the model drafts it.
*/
export function toolPresentVerb(toolName: string): string {
if (toolName === 'skill_view') {
return translateNow('assistant.tool.skillActivity.loading')
}
return CATEGORY_COPY[toolCategory(toolName)].present
}
@@ -135,8 +144,17 @@ export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean):
const liveCategory = narrating ? toolCategory(narrating.toolName) : null
const byCategory = new Map<RunCategory, ToolCallLike[]>()
const skillClauses: string[] = []
for (const tool of tools) {
const skill = skillActivityTitle(tool, live)
if (skill) {
skillClauses.push(skill)
continue
}
const category = toolCategory(tool.toolName)
const group = byCategory.get(category)
@@ -153,5 +171,20 @@ export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean):
return group ? [clause(category, group, category === liveCategory)] : []
})
return clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ')
const failed = tools.filter(tool => {
const result = parseMaybeObject(tool.result)
// Explicit success beats stale envelope errors, as in individual rows.
return (
result.success !== true &&
result.ok !== true &&
Boolean(tool.isError || result.success === false || result.ok === false || extractToolErrorMessage(tool.result))
)
}).length
if (failed) {
clauses.push(translateNow('assistant.tool.failedCalls', failed))
}
return [...skillClauses, ...clauses].map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ')
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { buildToolView } from './fallback-model'
import { summarizeToolRun } from './run-summary'
const skill = { type: 'tool-call' as const, toolName: 'skill_view', args: { name: 'research-notes' } }
describe('skill activity identity', () => {
it('names instruction loads, resource reads, failures and unobserved outcomes without claiming task completion', () => {
expect(buildToolView(skill, '').title).toBe('Loading skill: research-notes')
expect(buildToolView({ ...skill, result: { success: true } }, '').title).toBe('Loaded skill: research-notes')
const resource = { ...skill, args: { ...skill.args, file_path: 'references/example.md' } }
expect(buildToolView(resource, '').title).toBe('Reading skill resource: research-notes → references/example.md')
expect(buildToolView({ ...resource, result: 'resource body' }, '').title).toBe(
'Read skill resource: research-notes → references/example.md'
)
expect(buildToolView({ ...skill, isError: true, result: 'missing' }, '').title).toBe(
'Failed to load skill: research-notes'
)
expect(summarizeToolRun([skill], false)).toBe('Skill result unavailable: research-notes')
})
it('keeps each skill identity and failed outcome in mixed collapsed summaries', () => {
const summary = summarizeToolRun(
[
{ ...skill, result: 'instructions' },
{ ...skill, args: { name: 'second-skill' }, result: { error: 'not found' } },
{ toolName: 'terminal', args: { command: 'echo ok' }, result: { stdout: 'ok' } }
],
false
)
expect(summary).toContain('Loaded skill: research-notes')
expect(summary).toContain('failed to load skill: second-skill')
expect(summary).toContain('ran 1 command')
expect(summary).toContain('1 tool call failed')
})
})
@@ -0,0 +1,46 @@
import { translateNow } from '@/i18n'
import { firstStringField } from '@/lib/text'
import { extractToolErrorMessage } from '@/lib/tool-result-summary'
import { parseMaybeObject } from './fallback-model/format'
interface SkillCall {
args?: unknown
completedAt?: number
isError?: boolean
result?: unknown
toolName: string
}
/** Loading instructions is not the same action as reading a skill resource,
* and neither proves the task described by the skill has been completed. */
export function skillActivityTitle(part: SkillCall, live = true): string | undefined {
if (part.toolName !== 'skill_view' && part.toolName !== 'skills_list') {
return undefined
}
const args = parseMaybeObject(part.args)
const result = parseMaybeObject(part.result)
const failed = Boolean(
part.isError || extractToolErrorMessage(part.result) || result.success === false || result.ok === false
)
const pending = live && part.result === undefined && part.completedAt === undefined
const missing = !pending && part.result === undefined
const file = firstStringField(args, ['file_path'])
const name = firstStringField(args, ['name'])
const target = [name, file].filter(Boolean).join(' → ')
const keys =
part.toolName === 'skills_list'
? { pending: 'listing', done: 'listed', failed: 'listFailed' }
: file
? { pending: 'readingResource', done: 'readResource', failed: 'resourceFailed' }
: { pending: 'loading', done: 'loaded', failed: 'loadFailed' }
const key = failed ? keys.failed : missing ? 'unavailable' : pending ? keys.pending : keys.done
const label = translateNow(`assistant.tool.skillActivity.${key}`)
return target ? `${label}: ${target}` : label
}
@@ -478,14 +478,56 @@ describe('live tool run', () => {
})
})
it('cannot be collapsed while a tool is still running', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
it('honors explicit disclosure across live updates and completion', async () => {
const message = groupedPendingMessage()
const { container, rerender } = render(<GroupHarness message={message} />)
const toggle = () => container.querySelector('[data-tool-summary] button[aria-expanded]') as HTMLButtonElement
await waitFor(() => {
expect(container.querySelector('[data-tool-summary]')).not.toBeNull()
})
await waitFor(() => expect(toggle()).not.toBeNull())
fireEvent.click(toggle())
expect(toggle().getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-tool-ticker]')).toBeNull()
fireEvent.click(toggle())
const row = container.querySelector('[data-tool-ticker] [data-tool-row] button[aria-expanded="false"]')
expect(row).not.toBeNull()
fireEvent.click(row as Element)
await waitFor(() => expect(container.querySelector('[data-tool-ticker]')).toBeNull())
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull()
const next = {
...message,
content: [
...message.content,
{
type: 'tool-call',
toolCallId: 'read-next',
toolName: 'read_file',
args: { path: '/tmp/next' },
argsText: '{}'
}
]
} as ThreadMessage
rerender(<GroupHarness message={next} />)
await waitFor(() => expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(3))
rerender(<GroupHarness message={{ ...next, status: { type: 'complete', reason: 'stop' } }} />)
await waitFor(() => expect(toggle().getAttribute('aria-expanded')).toBe('true'))
fireEvent.click(toggle())
expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0)
})
it('updates named skill summaries when identifying arguments arrive late', async () => {
const message = groupedPendingMessage()
const first = { ...message.content[0], toolName: 'skill_view', args: {}, result: { success: true } }
const { container, rerender } = render(
<GroupHarness message={{ ...message, content: [first, message.content[1]] } as ThreadMessage} />
)
const summary = () => container.querySelector('[data-tool-summary]')?.textContent
await waitFor(() => expect(summary()).toContain('Loaded skill'))
const named = { ...first, args: { name: 'research-notes' } }
rerender(<GroupHarness message={{ ...message, content: [named, message.content[1]] } as ThreadMessage} />)
await waitFor(() => expect(summary()).toContain('research-notes'))
})
// Liveness used to also require an unresolved call, which is false for the
@@ -497,7 +539,7 @@ describe('live tool run', () => {
expect(await screen.findByText('Running 2 commands')).toBeTruthy()
expect(container.querySelector('[data-tool-ticker]')).not.toBeNull()
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull()
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull()
})
// The ticker is a one-line window, so a row opened inside it had its output
+13
View File
@@ -2901,6 +2901,19 @@ export const ar = defineLocale({
copyQuery: 'نسخ الاستعلام',
copyFile: 'نسخ الملف',
copyPath: 'نسخ المسار',
failedCalls: (count: number) => `عدد استدعاءات الأدوات الفاشلة: ${count}`,
skillActivity: {
loading: 'جارٍ تحميل المهارة',
loaded: 'تم تحميل المهارة',
loadFailed: 'تعذر تحميل المهارة',
readingResource: 'جارٍ قراءة مورد المهارة',
readResource: 'تمت قراءة مورد المهارة',
resourceFailed: 'تعذرت قراءة مورد المهارة',
listing: 'جارٍ عرض المهارات',
listed: 'تم عرض المهارات',
listFailed: 'تعذر عرض المهارات',
unavailable: 'نتيجة المهارة غير متاحة'
},
outputAlt: 'إخراج الأداة',
rawResponse: 'الرد الخام',
copyActivity: 'نسخ النشاط',
+13
View File
@@ -3964,6 +3964,19 @@ export const en: Translations = {
copyQuery: 'Copy query',
copyFile: 'Copy file',
copyPath: 'Copy path',
failedCalls: (count: number) => `${count} tool call${count === 1 ? '' : 's'} failed`,
skillActivity: {
loading: 'Loading skill',
loaded: 'Loaded skill',
loadFailed: 'Failed to load skill',
readingResource: 'Reading skill resource',
readResource: 'Read skill resource',
resourceFailed: 'Failed to read skill resource',
listing: 'Listing skills',
listed: 'Listed skills',
listFailed: 'Failed to list skills',
unavailable: 'Skill result unavailable'
},
outputAlt: 'Tool output',
rawResponse: 'Raw response',
copyActivity: 'Copy activity',
+13
View File
@@ -3359,6 +3359,19 @@ export const ja = defineLocale({
copyQuery: 'クエリをコピー',
copyFile: 'ファイルをコピー',
copyPath: 'パスをコピー',
failedCalls: (count: number) => `失敗したツール呼び出し: ${count}`,
skillActivity: {
loading: 'スキルを読み込み中',
loaded: 'スキルを読み込みました',
loadFailed: 'スキルの読み込みに失敗しました',
readingResource: 'スキルのリソースを読み込み中',
readResource: 'スキルのリソースを読み込みました',
resourceFailed: 'スキルのリソースの読み込みに失敗しました',
listing: 'スキル一覧を取得中',
listed: 'スキル一覧を取得しました',
listFailed: 'スキル一覧の取得に失敗しました',
unavailable: 'スキルの結果を取得できません'
},
outputAlt: 'ツール出力',
rawResponse: '生の応答',
copyActivity: 'アクティビティをコピー',
+13
View File
@@ -3661,6 +3661,19 @@ export const ru = defineLocale({
copyQuery: 'Копировать запрос',
copyFile: 'Копировать файл',
copyPath: 'Копировать путь',
failedCalls: (count: number) => `Вызовов с ошибкой: ${count}`,
skillActivity: {
loading: 'Загружается скилл',
loaded: 'Загружен скилл',
loadFailed: 'Не удалось загрузить скилл',
readingResource: 'Читается ресурс скилла',
readResource: 'Прочитан ресурс скилла',
resourceFailed: 'Не удалось прочитать ресурс скилла',
listing: 'Загружается список скиллов',
listed: 'Получен список скиллов',
listFailed: 'Не удалось получить список скиллов',
unavailable: 'Результат работы со скиллом недоступен'
},
outputAlt: 'Вывод инструмента',
rawResponse: 'Сырой ответ',
copyActivity: 'Копировать активность',
+13
View File
@@ -3490,6 +3490,19 @@ export interface Translations {
copyQuery: string
copyFile: string
copyPath: string
failedCalls: (count: number) => string
skillActivity: {
loading: string
loaded: string
loadFailed: string
readingResource: string
readResource: string
resourceFailed: string
listing: string
listed: string
listFailed: string
unavailable: string
}
outputAlt: string
rawResponse: string
copyActivity: string
+13
View File
@@ -3312,6 +3312,19 @@ export const zhHant = defineLocale({
copyQuery: '複製查詢',
copyFile: '複製檔案',
copyPath: '複製路徑',
failedCalls: (count: number) => `${count} 次工具呼叫失敗`,
skillActivity: {
loading: '正在載入技能',
loaded: '已載入技能',
loadFailed: '技能載入失敗',
readingResource: '正在讀取技能資源',
readResource: '已讀取技能資源',
resourceFailed: '技能資源讀取失敗',
listing: '正在列出技能',
listed: '已列出技能',
listFailed: '技能清單取得失敗',
unavailable: '技能結果無法使用'
},
outputAlt: '工具輸出',
rawResponse: '原始回應',
copyActivity: '複製活動',
+13
View File
@@ -4118,6 +4118,19 @@ export const zh = defineLocale({
copyQuery: '复制查询',
copyFile: '复制文件',
copyPath: '复制路径',
failedCalls: (count: number) => `${count} 次工具调用失败`,
skillActivity: {
loading: '正在加载技能',
loaded: '已加载技能',
loadFailed: '技能加载失败',
readingResource: '正在读取技能资源',
readResource: '已读取技能资源',
resourceFailed: '技能资源读取失败',
listing: '正在列出技能',
listed: '已列出技能',
listFailed: '技能列表获取失败',
unavailable: '技能结果不可用'
},
outputAlt: '工具输出',
rawResponse: '原始响应',
copyActivity: '复制活动',