mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): tool lifecycle states — live elapsed tick + failed glyph
This commit is contained in:
parent
b6dc49200d
commit
076aebc7e6
4 changed files with 265 additions and 12 deletions
|
|
@ -45,6 +45,9 @@ export interface ToolPartState {
|
|||
args?: Record<string, unknown>
|
||||
/** Tool wall-clock seconds (gateway `duration_s`), shown dim in the header. */
|
||||
duration?: number
|
||||
/** Local Date.now() stamped on `tool.start` — drives the live elapsed tick
|
||||
* while running (Epic 2.5). `duration` (gateway truth) wins once settled. */
|
||||
startedAt?: number
|
||||
/** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */
|
||||
omittedNote?: string
|
||||
/** FULL raw unified diff from file-edit tools (gateway `diff_unified`, 512KB-capped). */
|
||||
|
|
@ -224,6 +227,29 @@ function readOptNum(payload: { readonly [k: string]: unknown }, key: string): nu
|
|||
return typeof v === 'number' ? v : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed tool's failure signal, derived from the raw `result`: hermes tools
|
||||
* return `{"error": "…"}` JSON on failure, and the gateway never sets a
|
||||
* top-level `error` on tool.complete — so this is THE live trigger for the
|
||||
* failed lifecycle state (Epic 2.5). Flattened to one line for the header
|
||||
* subtitle; absent/empty/non-dict results yield undefined (not failed).
|
||||
*/
|
||||
function resultError(result: unknown): string | undefined {
|
||||
let v: unknown = result
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
v = JSON.parse(v)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined
|
||||
const e = (v as Record<string, unknown>)['error']
|
||||
if (typeof e !== 'string') return undefined
|
||||
const flat = e.replace(/\s+/g, ' ').trim()
|
||||
return flat ? flat.slice(0, 400) : undefined
|
||||
}
|
||||
|
||||
/** Render a raw tool `result` for display: strings as-is, anything else pretty
|
||||
* JSON — both then go through the same envelope-strip pipeline as result_text. */
|
||||
function stringifyResult(v: unknown): string | undefined {
|
||||
|
|
@ -632,7 +658,7 @@ export function createSessionStore() {
|
|||
setState(
|
||||
produce(draft => {
|
||||
const live = ensureAssistant(draft)
|
||||
const part: ToolPartState = { type: 'tool', id, name, state: 'running' }
|
||||
const part: ToolPartState = { type: 'tool', id, name, state: 'running', startedAt: Date.now() }
|
||||
if (argsPreview) part.argsPreview = argsPreview
|
||||
if (argsText) part.argsText = argsText
|
||||
;(live.parts ??= []).push(part)
|
||||
|
|
@ -644,7 +670,9 @@ export function createSessionStore() {
|
|||
const id = readStr(event.payload, 'tool_id')
|
||||
if (!id) break
|
||||
const name = readStr(event.payload, 'name')
|
||||
const error = readStr(event.payload, 'error')
|
||||
// explicit payload error wins; else derive from the `{"error": …}` result
|
||||
// convention (the only failure signal the live gateway actually ships).
|
||||
const error = readStr(event.payload, 'error') ?? resultError(event.payload['result'])
|
||||
const summary = readStr(event.payload, 'summary')
|
||||
// `result_text` is verbose-gated, but the raw `result` is ALWAYS sent —
|
||||
// when the verbose text is absent, derive the display body from `result`
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* (found by scanning the frame). The long-output cap is asserted at the Body
|
||||
* level (a tall frame would otherwise hide the trailing note).
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { createSessionStore, type ToolPartState } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
|
|
@ -418,6 +418,153 @@ describe('file tool — output suppression under a rendered diff (no raw JSON, e
|
|||
})
|
||||
})
|
||||
|
||||
describe('tool lifecycle states — running / done / failed (Epic 2.5)', () => {
|
||||
// Fake ONLY setInterval/clearInterval/Date: the shared elapsed tick + the
|
||||
// Date.now() it invalidates. setTimeout/microtasks stay REAL — the test
|
||||
// renderer's settle dance (flush/waitForFrame) depends on them, and the
|
||||
// render scheduler itself times via performance.now (unfaked).
|
||||
const FAKED = ['setInterval', 'clearInterval', 'Date'] as const
|
||||
|
||||
test('store: tool.start stamps startedAt; tool.complete settles the gateway duration', () => {
|
||||
vi.useFakeTimers({ toFake: [...FAKED] })
|
||||
try {
|
||||
vi.setSystemTime(1_750_000_000_000)
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 'l1', name: 'terminal', context: 'sleep 8' } })
|
||||
const live = store.state.messages[store.state.messages.length - 1]
|
||||
const part = live?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'l1')
|
||||
expect(part?.startedAt).toBe(1_750_000_000_000)
|
||||
expect(part?.state).toBe('running')
|
||||
// the gateway's duration_s remains the settled truth, startedAt untouched
|
||||
store.apply({ type: 'tool.complete', payload: { tool_id: 'l1', name: 'terminal', duration_s: 8.2 } })
|
||||
expect(part?.state).toBe('complete')
|
||||
expect(part?.duration).toBe(8.2)
|
||||
expect(part?.startedAt).toBe(1_750_000_000_000)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test('store: a `{"error": …}` result derives part.error (the gateway never ships a top-level error)', () => {
|
||||
const store = createSessionStore()
|
||||
seedTool(
|
||||
store,
|
||||
{ tool_id: 'l2', name: 'read_file' },
|
||||
{ tool_id: 'l2', name: 'read_file', result: { error: 'File not found:\n /nope.txt' } }
|
||||
)
|
||||
const last = store.state.messages[store.state.messages.length - 1]
|
||||
const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'l2')
|
||||
expect(part?.error).toBe('File not found: /nope.txt') // flattened to one line
|
||||
// …and a clean result stays un-failed
|
||||
seedTool(
|
||||
store,
|
||||
{ tool_id: 'l3', name: 'terminal' },
|
||||
{ tool_id: 'l3', name: 'terminal', result: { exit_code: 0, output: 'ok' } }
|
||||
)
|
||||
const part3 = store.state.messages[store.state.messages.length - 1]?.parts?.find(
|
||||
(p): p is ToolPartState => p.type === 'tool' && p.id === 'l3'
|
||||
)
|
||||
expect(part3?.error).toBeUndefined()
|
||||
})
|
||||
|
||||
test('running tool shows ⚡ + a LIVE elapsed that advances with the clock — and no expand glyph', async () => {
|
||||
vi.useFakeTimers({ toFake: [...FAKED] })
|
||||
try {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 'r1', name: 'terminal', context: 'sleep 8' } })
|
||||
|
||||
const probe = await mountApp(store)
|
||||
try {
|
||||
const f0 = await probe.waitForFrame(f => f.includes('terminal'))
|
||||
expect(f0).toContain('⚡') // running head glyph
|
||||
expect(f0).toContain('sleep 8') // subtitle shows while running
|
||||
expect(f0).toContain('· 0s') // elapsed starts at zero
|
||||
expect(f0).not.toContain('▶') // NO expand affordance while running
|
||||
expect(f0).not.toContain('▼')
|
||||
|
||||
vi.advanceTimersByTime(3000) // shared tick fires 3× → repaint
|
||||
const f3 = await probe.waitForFrame(f => f.includes('· 3s'))
|
||||
expect(f3).toContain('· 3s')
|
||||
expect(f3).not.toContain('· 0s')
|
||||
|
||||
vi.advanceTimersByTime(9000) // …and keeps advancing (12s total)
|
||||
const f12 = await probe.waitForFrame(f => f.includes('· 12s'))
|
||||
expect(f12).toContain('· 12s')
|
||||
expect(f12).toContain('⚡') // still running, still no ▶/▼
|
||||
expect(f12).not.toContain('▶')
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test('failed tool reads as failed from the HEAD GLYPH (✗) and stays expandable when there is a body', async () => {
|
||||
const store = createSessionStore()
|
||||
seedTool(
|
||||
store,
|
||||
{ tool_id: 'e1', name: 'terminal', context: 'false' },
|
||||
{
|
||||
tool_id: 'e1',
|
||||
name: 'terminal',
|
||||
args: { command: 'false' },
|
||||
error: 'exit status 1',
|
||||
result_text: 'boom line one\nboom line two',
|
||||
duration_s: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
const probe = await mountApp(store)
|
||||
try {
|
||||
const frame = await probe.waitForFrame(f => f.includes('terminal'))
|
||||
const row = frame.split('\n').find(line => line.includes('terminal')) ?? ''
|
||||
// ✗ IN the head-glyph position (immediately before the name, after the
|
||||
// assistant row's `⚕` gutter), replacing the expand glyph…
|
||||
expect(row).toContain('✗ terminal')
|
||||
expect(row).not.toContain('▶')
|
||||
expect(row).toContain('✗ exit status 1') // error subtitle stays
|
||||
expect(row).toContain('(2 lines)') // body presence still signposted
|
||||
|
||||
await clickHeader(probe, 'terminal') // still expandable: body has the output
|
||||
const expanded = await probe.waitForFrame(f => f.includes('boom line one'))
|
||||
expect(expanded).toContain('boom line one')
|
||||
expect(expanded).toContain('boom line two')
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('settled success keeps the ▶/▼ + duration contract (glyph never error-colored ✗)', async () => {
|
||||
const store = createSessionStore()
|
||||
seedTool(
|
||||
store,
|
||||
{ tool_id: 'k1', name: 'terminal', context: 'ls' },
|
||||
{ tool_id: 'k1', name: 'terminal', args: { command: 'ls' }, result_text: 'a\nb', duration_s: 0.3 }
|
||||
)
|
||||
|
||||
const probe = await mountApp(store)
|
||||
try {
|
||||
const frame = await probe.waitForFrame(f => f.includes('terminal'))
|
||||
const row = frame.split('\n').find(line => line.includes('terminal')) ?? ''
|
||||
expect(row).toContain('▶ terminal') // head-glyph position, after the ⚕ gutter
|
||||
expect(row).toContain('· 0.3s')
|
||||
expect(row).not.toContain('✗')
|
||||
expect(row).not.toContain('⚡')
|
||||
|
||||
await clickHeader(probe, 'terminal')
|
||||
const expanded = await probe.waitForFrame(f => f.includes('▼'))
|
||||
expect(expanded).toContain('▼ terminal')
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('redaction precedence — gateway args_text wins over raw args (security)', () => {
|
||||
// The gateway redacts verbose `args_text` (server.py _tool_args_text) but
|
||||
// sends the raw `args` dict on tool.complete UNREDACTED. structuredArgs must
|
||||
|
|
|
|||
38
ui-opentui/src/view/elapsed.ts
Normal file
38
ui-opentui/src/view/elapsed.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* elapsed.ts — the SHARED 1-second tick behind every running tool's live
|
||||
* elapsed counter (Epic 2.5). ONE module-level signal driven by ONE
|
||||
* setInterval for the whole app — never a timer per part. Refcounted: the
|
||||
* interval starts when the first subscriber mounts and is cleared when the
|
||||
* last one cleans up, so an idle transcript schedules zero wakeups.
|
||||
*
|
||||
* `useElapsedTick()` must be called inside a reactive scope that exists only
|
||||
* while ticking is needed (e.g. a component under `<Show when={running()}>`)
|
||||
* — its `onCleanup` is what releases the subscription. Read the returned
|
||||
* accessor in a TRACKING scope (JSX/memo/effect); the tick value itself is
|
||||
* meaningless — it exists to invalidate `Date.now()`-based computations.
|
||||
*/
|
||||
import { createSignal, onCleanup } from 'solid-js'
|
||||
|
||||
const [tick, setTick] = createSignal(0)
|
||||
|
||||
let subscribers = 0
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
/** Subscribe the current reactive scope to the shared 1s tick (refcounted). */
|
||||
export function useElapsedTick(): () => number {
|
||||
subscribers++
|
||||
timer ??= setInterval(() => setTick(t => t + 1), 1000)
|
||||
onCleanup(() => {
|
||||
subscribers--
|
||||
if (subscribers <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
})
|
||||
return tick
|
||||
}
|
||||
|
||||
/** Whole seconds since `startedAt` (clamped at 0) — pair with the tick. */
|
||||
export function elapsedSeconds(startedAt: number): number {
|
||||
return Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
|
||||
}
|
||||
|
|
@ -5,13 +5,18 @@
|
|||
* what's INSIDE varies per tool and is dispatched through the tool renderer
|
||||
* registry (`view/tools/registry.tsx`, Epic 2.2):
|
||||
*
|
||||
* ⚡ terminal sleep 8 · 12s ← running (elapsed ticks live)
|
||||
* ▶ terminal ls -la src · 0.3s (12 lines) ← collapsed (default)
|
||||
* ▼ terminal ls -la src · 0.3s ← expanded header
|
||||
* │ <renderer body> ← labeled fields / output / …
|
||||
* ✗ terminal ✗ exit 1 · 0.1s (3 lines) ← failed (error-colored glyph)
|
||||
*
|
||||
* `▶`/`▼` marks expandable tools; clicking the header toggles it (wrapped in
|
||||
* useScrollAnchor so expanding never yanks the viewport). Running tools show
|
||||
* `name …`. The header row is chrome (selectable=false) — a free-form drag
|
||||
* Lifecycle is legible from the HEAD GLYPH alone (Epic 2.5): `⚡` running (with
|
||||
* a live `· Ns` elapsed off the shared 1s tick in `elapsed.ts` — never a timer
|
||||
* per part), `▶`/`▼` settled-expandable, `✗` failed (theme error color).
|
||||
* Clicking an expandable header toggles it (wrapped in useScrollAnchor so
|
||||
* expanding never yanks the viewport); running parts have no expand
|
||||
* affordance. The header row is chrome (selectable=false) — a free-form drag
|
||||
* copies only the expanded body content. Fully themed (no hardcoded styles).
|
||||
*/
|
||||
import { type ToolPartState } from '../logic/store.ts'
|
||||
|
|
@ -19,6 +24,7 @@ import { useDimensions } from './dimensions.tsx'
|
|||
import { createSignal, Show } from 'solid-js'
|
||||
|
||||
import { truncate } from '../logic/toolOutput.ts'
|
||||
import { elapsedSeconds, useElapsedTick } from './elapsed.ts'
|
||||
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||
import { useSessionInfo } from './sessionInfo.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
|
@ -35,6 +41,31 @@ function fmtDuration(s: number): string {
|
|||
return r ? `${m}m ${r}s` : `${m}m`
|
||||
}
|
||||
|
||||
/** Live elapsed format — whole seconds (the tick advances 1s at a time). */
|
||||
function fmtElapsed(s: number): string {
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const r = s % 60
|
||||
return r ? `${m}m ${r}s` : `${m}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Live ` · 12s` elapsed for a RUNNING part. Mounted only under the running
|
||||
* `<Show>`, so its useElapsedTick subscription starts/stops the SHARED 1s
|
||||
* interval with the part's lifecycle (the last cleanup clears it). Falls back
|
||||
* to the plain ` …` marker when startedAt is unknown (e.g. a tool.complete
|
||||
* that arrived without a local tool.start).
|
||||
*/
|
||||
function RunningElapsed(props: { startedAt: number | undefined }) {
|
||||
const theme = useTheme()
|
||||
const tick = useElapsedTick()
|
||||
const text = () => {
|
||||
tick() // re-read every shared tick — Date.now() alone is not reactive
|
||||
return props.startedAt === undefined ? ' …' : ` · ${fmtElapsed(elapsedSeconds(props.startedAt))}`
|
||||
}
|
||||
return <span style={{ fg: theme().color.muted }}>{text()}</span>
|
||||
}
|
||||
|
||||
export function ToolPart(props: { part: ToolPartState }) {
|
||||
const theme = useTheme()
|
||||
const dims = useDimensions()
|
||||
|
|
@ -56,10 +87,14 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
// Optional `+N −M` change summary (file tools) — themed, settled parts only.
|
||||
const stats = () => (running() || props.part.error ? undefined : renderer().stats?.(props.part))
|
||||
|
||||
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
||||
// Failed parts are legible from the glyph alone: `✗` in the head position
|
||||
// (error-colored), regardless of expandability — `(N lines)` still marks an
|
||||
// expandable body. `error` only lands on tool.complete, so running stays ⚡.
|
||||
const failed = () => !running() && Boolean(props.part.error)
|
||||
const headGlyph = () => (failed() ? '✗' : collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
||||
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
|
||||
// as the dim, secondary tier below the bright assistant answer (Ink hierarchy).
|
||||
const headColor = () => (props.part.error ? theme().color.error : theme().color.accent)
|
||||
const headColor = () => (failed() ? theme().color.error : theme().color.accent)
|
||||
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
|
||||
|
||||
return (
|
||||
|
|
@ -80,10 +115,10 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
never the header label. */}
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>{props.part.name}</span>
|
||||
<Show when={running()}>
|
||||
<span style={{ fg: theme().color.muted }}> …</span>
|
||||
</Show>
|
||||
<Show when={!running() && subtitle()}>
|
||||
{/* subtitle shows while running too (the gateway argsPreview — e.g.
|
||||
the command being executed) so a running tool reads `⚡ terminal
|
||||
sleep 8 · 12s`, Ink parity. */}
|
||||
<Show when={subtitle()}>
|
||||
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
|
||||
{` ${truncate(subtitle(), subWidth())}`}
|
||||
</span>
|
||||
|
|
@ -106,6 +141,11 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
<Show when={!running() && props.part.duration !== undefined}>
|
||||
<span style={{ fg: theme().color.muted }}>{` · ${fmtDuration(props.part.duration ?? 0)}`}</span>
|
||||
</Show>
|
||||
{/* live elapsed (running only) — the <Show> scopes the shared-tick
|
||||
subscription to the running lifecycle (see RunningElapsed). */}
|
||||
<Show when={running()}>
|
||||
<RunningElapsed startedAt={props.part.startedAt} />
|
||||
</Show>
|
||||
<Show when={collapsible() && !expanded() && lines().length > 1}>
|
||||
<span style={{ fg: theme().color.muted }}>{` (${lines().length} lines)`}</span>
|
||||
</Show>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue