802 lines
31 KiB
TypeScript
802 lines
31 KiB
TypeScript
/**
|
||
* Tool renderer tests (Epics 2.2 + 2.4). Headless frames through the real App
|
||
* tree: the registry's default renderer turns args into LABELED FIELDS — the
|
||
* acceptance gate asserts NO raw JSON syntax (`{"` / `":`) ever reaches the
|
||
* frame for tool parts, collapsed or expanded — delegate_task carries the
|
||
* Ink-parity "(/agents to monitor)" hint, and the bash renderer shows the
|
||
* command verbatim collapsed + the full (EXPANDED_MAX-capped) output expanded.
|
||
* Expansion goes through the REAL mouse path: mockMouse clicks the header row
|
||
* (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, vi } from 'vitest'
|
||
|
||
import { createSessionStore, type ToolPartState } from '../logic/store.ts'
|
||
import { DEFAULT_THEME } from '../logic/theme.ts'
|
||
import { App } from '../view/App.tsx'
|
||
import { reasoningLabelStyle } from '../view/reasoningPart.tsx'
|
||
import { ThemeProvider } from '../view/theme.tsx'
|
||
import { toolNameStyle } from '../view/toolPart.tsx'
|
||
import { BashToolBody, commandOf } from '../view/tools/bashTool.tsx'
|
||
import { diffOutputPlan, FileToolBody } from '../view/tools/fileTool.tsx'
|
||
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
||
|
||
type Store = ReturnType<typeof createSessionStore>
|
||
|
||
/** Seed a settled assistant turn containing exactly the given tool call. */
|
||
function seedTool(store: Store, start: Record<string, unknown>, complete: Record<string, unknown>) {
|
||
store.apply({ type: 'gateway.ready' })
|
||
store.apply({ type: 'message.start' })
|
||
store.apply({ type: 'tool.start', payload: start })
|
||
store.apply({ type: 'tool.complete', payload: complete })
|
||
store.apply({ type: 'message.complete' })
|
||
}
|
||
|
||
async function mountApp(store: Store, width = 80, height = 24): Promise<RenderProbe> {
|
||
return renderProbe(
|
||
() => (
|
||
<ThemeProvider theme={() => store.state.theme}>
|
||
<App store={store} />
|
||
</ThemeProvider>
|
||
),
|
||
{ width, height }
|
||
)
|
||
}
|
||
|
||
/** Click the tool header row (the line containing `name`) to expand/collapse. */
|
||
async function clickHeader(probe: RenderProbe, name: string): Promise<void> {
|
||
const frame = await probe.waitForFrame(f => f.includes(name))
|
||
const rows = frame.split('\n')
|
||
const y = rows.findIndex(line => line.includes(name))
|
||
expect(y).toBeGreaterThanOrEqual(0)
|
||
const x = (rows[y] ?? '').indexOf(name)
|
||
await probe.click(x, y)
|
||
}
|
||
|
||
describe('tool renderer registry — labeled-args default (Epic 2.2)', () => {
|
||
test('an unmapped MCP-ish tool with nested args renders labeled fields, never raw JSON', async () => {
|
||
const store = createSessionStore()
|
||
seedTool(
|
||
store,
|
||
{ tool_id: 'm1', name: 'mcp_lookup' },
|
||
{
|
||
tool_id: 'm1',
|
||
name: 'mcp_lookup',
|
||
args: {
|
||
query: 'hermes agent',
|
||
options: { depth: 2, mode: 'fast', cache: true },
|
||
limit: 5
|
||
},
|
||
duration_s: 0.4,
|
||
result_text: 'one result found'
|
||
}
|
||
)
|
||
|
||
const probe = await mountApp(store)
|
||
try {
|
||
// collapsed: header only, and already no JSON syntax anywhere
|
||
const collapsed = await probe.waitForFrame(f => f.includes('mcp_lookup'))
|
||
expect(collapsed).not.toContain('{"')
|
||
expect(collapsed).not.toContain('":')
|
||
|
||
await clickHeader(probe, 'mcp_lookup')
|
||
const expanded = await probe.waitForFrame(f => f.includes('query'))
|
||
// labeled key → value rows (string verbatim, number via String)
|
||
expect(expanded).toContain('query')
|
||
expect(expanded).toContain('hermes agent')
|
||
expect(expanded).toContain('limit')
|
||
expect(expanded).toContain('5')
|
||
// nested object summarized, not dumped
|
||
expect(expanded).toContain('options')
|
||
expect(expanded).toContain('(3 fields)')
|
||
// the output body still renders (envelope-stripped store text)
|
||
expect(expanded).toContain('one result found')
|
||
// THE acceptance gate: no raw JSON syntax in the tool render
|
||
expect(expanded).not.toContain('{"')
|
||
expect(expanded).not.toContain('":')
|
||
expect(expanded).not.toContain('depth') // nested internals stay summarized
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
|
||
test('delegate_task gets the default renderer plus the muted "(/agents to monitor)" hint', async () => {
|
||
const store = createSessionStore()
|
||
seedTool(
|
||
store,
|
||
{ tool_id: 'd1', name: 'delegate_task', context: 'research opentui' },
|
||
{
|
||
tool_id: 'd1',
|
||
name: 'delegate_task',
|
||
args: { goal: 'research opentui', model: 'fast' },
|
||
result_text: 'spawned'
|
||
}
|
||
)
|
||
|
||
const probe = await mountApp(store)
|
||
try {
|
||
const frame = await probe.waitForFrame(f => f.includes('(/agents to monitor)'))
|
||
expect(frame).toContain('delegate_task')
|
||
expect(frame).toContain('research opentui') // primary-arg preview still leads
|
||
expect(frame).not.toContain('{"') // hint or not — still no raw JSON
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('bash tool renderer — command + full output (Epic 2.4)', () => {
|
||
test('collapsed header shows the invoked command VERBATIM (args win over the gateway preview)', async () => {
|
||
const store = createSessionStore()
|
||
seedTool(
|
||
store,
|
||
// the gateway's one-line preview is truncated — args.command is the truth
|
||
{ tool_id: 'b1', name: 'terminal', context: 'grep -rn needle' },
|
||
{
|
||
tool_id: 'b1',
|
||
name: 'terminal',
|
||
args: { command: 'grep -rn needle src/ | head -5', timeout: 60 },
|
||
duration_s: 0.2,
|
||
result_text: 'a.ts:1:needle\nb.ts:2:needle\nc.ts:3:needle'
|
||
}
|
||
)
|
||
|
||
const probe = await mountApp(store)
|
||
try {
|
||
const frame = await probe.waitForFrame(f => f.includes('grep -rn needle src/ | head -5'))
|
||
expect(frame).toContain('terminal')
|
||
expect(frame).toContain('grep -rn needle src/ | head -5') // verbatim, not the preview
|
||
expect(frame).toContain('(3 lines)') // output stays behind the expand affordance
|
||
expect(frame).not.toContain('a.ts:1:needle') // collapsed → no output shown
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
|
||
test('expanded shows the $ command and the FULL (short) output', async () => {
|
||
const store = createSessionStore()
|
||
seedTool(
|
||
store,
|
||
{ tool_id: 'b2', name: 'terminal' },
|
||
{
|
||
tool_id: 'b2',
|
||
name: 'terminal',
|
||
args: { command: 'ls' },
|
||
result_text: 'alpha.txt\nbeta.txt\ngamma.txt'
|
||
}
|
||
)
|
||
|
||
const probe = await mountApp(store)
|
||
try {
|
||
await clickHeader(probe, 'terminal')
|
||
const expanded = await probe.waitForFrame(f => f.includes('alpha.txt'))
|
||
expect(expanded).toContain('$ ls') // the invocation, prompt-prefixed
|
||
expect(expanded).toContain('output') // section label
|
||
expect(expanded).toContain('alpha.txt') // full output…
|
||
expect(expanded).toContain('beta.txt')
|
||
expect(expanded).toContain('gamma.txt') // …down to the last line
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
|
||
test('long output is capped to EXPANDED_MAX with an honest "+N more lines" note', async () => {
|
||
const lines = Array.from({ length: 250 }, (_, i) => `line-${String(i + 1).padStart(3, '0')}`)
|
||
const part: ToolPartState = {
|
||
type: 'tool',
|
||
id: 'b3',
|
||
name: 'execute_code',
|
||
state: 'complete',
|
||
args: { code: 'for i in range(250): print(i)' },
|
||
resultText: lines.join('\n')
|
||
}
|
||
// Body-level mount (tall frame so the trailing note row is on screen).
|
||
const probe = await renderProbe(
|
||
() => (
|
||
<ThemeProvider>
|
||
<BashToolBody part={part} width={70} />
|
||
</ThemeProvider>
|
||
),
|
||
{ width: 80, height: 210 }
|
||
)
|
||
try {
|
||
const frame = await probe.waitForFrame(f => f.includes('+50 more lines'))
|
||
expect(frame).toContain('$ for i in range(250): print(i)')
|
||
expect(frame).toContain('line-001') // the cap keeps the HEAD of the output
|
||
expect(frame).toContain('line-200') // …up to EXPANDED_MAX
|
||
expect(frame).not.toContain('line-201') // the rest is honestly elided
|
||
expect(frame).toContain('… +50 more lines')
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
|
||
test('a gateway-capped result renders the tidy omitted note', async () => {
|
||
const part: ToolPartState = {
|
||
type: 'tool',
|
||
id: 'b4',
|
||
name: 'terminal',
|
||
state: 'complete',
|
||
args: { command: 'cat big.log' },
|
||
resultText: 'tail line one\ntail line two',
|
||
omittedNote: '120 lines / 9001 chars'
|
||
}
|
||
const probe = await renderProbe(
|
||
() => (
|
||
<ThemeProvider>
|
||
<BashToolBody part={part} width={70} />
|
||
</ThemeProvider>
|
||
),
|
||
{ width: 80, height: 12 }
|
||
)
|
||
try {
|
||
const frame = await probe.waitForFrame(f => f.includes('omitted'))
|
||
expect(frame).toContain('tail line one')
|
||
expect(frame).toContain('… omitted 120 lines / 9001 chars')
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('file tool renderer — relative path + diff stats (Epic 2.3)', () => {
|
||
// NOTE: the EXPANDED native <diff> is deliberately untested here — like
|
||
// <markdown> it tokenizes via Tree-sitter ASYNCHRONOUSLY and may not settle
|
||
// in the headless renderer. The diff visuals belong to the live smoke; these
|
||
// tests pin the LOGIC surface (collapsed header, fallback body).
|
||
const DIFF = ['--- a/src/main.ts', '+++ b/src/main.ts', '@@ -1,3 +1,4 @@', ' ctx', '-old', '+new', '+more'].join('\n')
|
||
|
||
test('collapsed write_file shows the cwd-RELATIVE path and the themed +N −M stats', async () => {
|
||
const store = createSessionStore()
|
||
store.apply({ type: 'session.info', payload: { cwd: '/home/u/proj' } })
|
||
seedTool(
|
||
store,
|
||
{ tool_id: 'f1', name: 'write_file', context: '/home/u/proj/src/main.ts' },
|
||
{
|
||
tool_id: 'f1',
|
||
name: 'write_file',
|
||
args: { path: '/home/u/proj/src/main.ts', content: 'new\nmore\n' },
|
||
diff_unified: DIFF + '\n',
|
||
duration_s: 0.1,
|
||
result: '{"success": true}'
|
||
}
|
||
)
|
||
|
||
const probe = await mountApp(store)
|
||
try {
|
||
const frame = await probe.waitForFrame(f => f.includes('write_file'))
|
||
expect(frame).toContain('src/main.ts') // relative to the session cwd…
|
||
expect(frame).not.toContain('/home/u/proj/src/main.ts') // …never absolute
|
||
expect(frame).toContain('+2') // added (excludes the +++ header)
|
||
expect(frame).toContain('−1') // removed (excludes the --- header)
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
|
||
test('read_file gets NO diff body — expanded falls back to labeled fields + output', async () => {
|
||
const store = createSessionStore()
|
||
store.apply({ type: 'session.info', payload: { cwd: '/home/u/proj' } })
|
||
seedTool(
|
||
store,
|
||
{ tool_id: 'f2', name: 'read_file' },
|
||
{
|
||
tool_id: 'f2',
|
||
name: 'read_file',
|
||
args: { path: '/home/u/proj/notes.md', limit: 50 },
|
||
result_text: '1|# Notes\n2|hello'
|
||
}
|
||
)
|
||
|
||
const probe = await mountApp(store)
|
||
try {
|
||
const collapsed = await probe.waitForFrame(f => f.includes('read_file'))
|
||
expect(collapsed).toContain('notes.md') // relpath subtitle
|
||
expect(collapsed).not.toContain('+0') // no diff → no stats summary
|
||
|
||
await clickHeader(probe, 'read_file')
|
||
const expanded = await probe.waitForFrame(f => f.includes('limit'))
|
||
expect(expanded).toContain('path') // default labeled fields…
|
||
expect(expanded).toContain('50')
|
||
expect(expanded).toContain('# Notes') // …and the output body
|
||
expect(expanded).not.toContain('@@') // never a diff
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
|
||
test('store: tool.complete diff_unified lands on the part with computed stats', () => {
|
||
const store = createSessionStore()
|
||
seedTool(
|
||
store,
|
||
{ tool_id: 'f3', name: 'patch' },
|
||
{
|
||
tool_id: 'f3',
|
||
name: 'patch',
|
||
args: { mode: 'replace', path: 'x.py' },
|
||
diff_unified: DIFF
|
||
}
|
||
)
|
||
const last = store.state.messages[store.state.messages.length - 1]
|
||
const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'f3')
|
||
expect(part?.diffUnified).toBe(DIFF)
|
||
expect(part?.diffStats).toEqual({ added: 2, removed: 1 })
|
||
})
|
||
})
|
||
|
||
describe('file tool — output suppression under a rendered diff (no raw JSON, ever)', () => {
|
||
// A file-edit result is a JSON record whose payload IS the diff. In a verbose
|
||
// session the gateway REDACTS + CAPS result_text, so it can arrive truncated
|
||
// mid-JSON (unparseable) — that JSON-looking blob must never render below the
|
||
// native diff. Plain-text results (lint tails etc.) must still render.
|
||
const DIFF = ['--- a/x.py', '+++ b/x.py', '@@ -1,2 +1,2 @@', ' ctx', '-old', '+new'].join('\n')
|
||
|
||
const part = (resultText: string): ToolPartState => ({
|
||
type: 'tool',
|
||
id: 'fp1',
|
||
name: 'patch',
|
||
state: 'complete',
|
||
args: { path: '/p/x.py' },
|
||
resultText,
|
||
diffUnified: DIFF,
|
||
diffStats: { added: 1, removed: 1 }
|
||
})
|
||
|
||
test('diffOutputPlan: truncated/unparseable JSON is suppressed; plain text renders; JSON warnings surface', () => {
|
||
// gateway-capped mid-JSON (unparseable, still contains "diff") → suppress
|
||
const capped = '{"success": true, "diff": "--- a/x.py\\n+++ b/x.py\\n@@ -1,2 +1'
|
||
expect(diffOutputPlan(part(capped))).toEqual({ kind: 'suppress' })
|
||
// intact JSON echo of the diff → suppress
|
||
expect(diffOutputPlan(part(JSON.stringify({ success: true, diff: DIFF })))).toEqual({ kind: 'suppress' })
|
||
// plain text (lint tail) → full output block
|
||
expect(diffOutputPlan(part('warning: trailing whitespace on line 3'))).toEqual({ kind: 'output' })
|
||
// parseable JSON carrying real non-diff signal → just the notes
|
||
expect(diffOutputPlan(part(JSON.stringify({ success: true, diff: DIFF, warning: 'mode fallback' })))).toEqual({
|
||
kind: 'notes',
|
||
notes: [['warning', 'mode fallback']]
|
||
})
|
||
})
|
||
|
||
test('diffOutputPlan: tail-capped echo that LOST the JSON head (normalized to diff lines) is suppressed', () => {
|
||
// A long file-edit JSON tail-capped past its `{"success"…` head: the store
|
||
// un-escapes the literal \n so it arrives as plain lines that ARE diff
|
||
// lines (first/last cut mid-line) — live bug shape from the v6 smoke.
|
||
const tallDiff = [
|
||
'--- a/x.py',
|
||
'+++ b/x.py',
|
||
'@@ -1,1 +1,9 @@',
|
||
' ctx',
|
||
...Array.from({ length: 8 }, (_, i) => `+def fn_${i}() -> int: return ${i}`)
|
||
].join('\n')
|
||
const echoTail = [
|
||
'n 1', // cut mid-line
|
||
...Array.from({ length: 6 }, (_, i) => `+def fn_${i + 2}() -> int: return ${i + 2}`),
|
||
'", "files_modified": ["/p/x.py' // cut mid-JSON
|
||
].join('\n')
|
||
expect(diffOutputPlan({ ...part(echoTail), diffUnified: tallDiff })).toEqual({ kind: 'suppress' })
|
||
// …but a genuine plain-text tail sharing no lines with the diff still renders
|
||
const lintTail = ['x.py:3: W291 trailing whitespace', 'x.py:9: E302 expected 2 blank lines', '2 warnings'].join(
|
||
'\n'
|
||
)
|
||
expect(diffOutputPlan({ ...part(lintTail), diffUnified: tallDiff })).toEqual({ kind: 'output' })
|
||
})
|
||
|
||
test('TRUNCATED JSON result under a rendered diff → NO output block in the frame', async () => {
|
||
const capped = '{"success": true, "diff": "--- a/x.py\\n+++ b/x.py\\n@@ -1,2 +1'
|
||
const probe = await renderProbe(
|
||
() => (
|
||
<ThemeProvider>
|
||
<FileToolBody part={part(capped)} width={70} />
|
||
</ThemeProvider>
|
||
),
|
||
{ width: 80, height: 16 }
|
||
)
|
||
try {
|
||
// wait for the native <diff> to paint (Tree-sitter settles async)
|
||
const frame = await probe.waitForFrame(f => f.includes('new'))
|
||
expect(frame).not.toContain('output') // no output section label
|
||
expect(frame).not.toContain('{"') // and never raw JSON
|
||
expect(frame).not.toContain('success')
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
|
||
test('plain-text result under a rendered diff → output block still shown', async () => {
|
||
const probe = await renderProbe(
|
||
() => (
|
||
<ThemeProvider>
|
||
<FileToolBody part={part('warning: trailing whitespace on line 3')} width={70} />
|
||
</ThemeProvider>
|
||
),
|
||
{ width: 80, height: 16 }
|
||
)
|
||
try {
|
||
const frame = await probe.waitForFrame(f => f.includes('trailing whitespace'))
|
||
expect(frame).toContain('output') // labeled output section
|
||
expect(frame).toContain('warning: trailing whitespace on line 3')
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
})
|
||
|
||
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('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only env var)', () => {
|
||
const ENV = 'HERMES_TUI_TOOL_OUTPUT_LINES'
|
||
|
||
/** Run `fn` with the flag set to `value` (undefined → unset), restoring after. */
|
||
async function withFlag(value: string | undefined, fn: () => Promise<void> | void) {
|
||
const prev = process.env[ENV]
|
||
if (value === undefined) delete process.env[ENV]
|
||
else process.env[ENV] = value
|
||
try {
|
||
await fn()
|
||
} finally {
|
||
if (prev === undefined) delete process.env[ENV]
|
||
else process.env[ENV] = prev
|
||
}
|
||
}
|
||
|
||
test('flag=0 (unlimited): a 250-line output renders ALL lines with NO "+N more lines" note', async () => {
|
||
await withFlag('0', async () => {
|
||
const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`)
|
||
const part: ToolPartState = {
|
||
type: 'tool',
|
||
id: 'u1',
|
||
name: 'terminal',
|
||
state: 'complete',
|
||
args: { command: 'seq 1 250' },
|
||
resultText: lines.join('\n')
|
||
}
|
||
const probe = await renderProbe(
|
||
() => (
|
||
<ThemeProvider>
|
||
<BashToolBody part={part} width={70} />
|
||
</ThemeProvider>
|
||
),
|
||
{ width: 80, height: 260 }
|
||
)
|
||
try {
|
||
const frame = await probe.waitForFrame(f => f.includes('row-250'))
|
||
expect(frame).toContain('row-001')
|
||
expect(frame).toContain('row-200')
|
||
expect(frame).toContain('row-201') // beyond the old 200-line cap…
|
||
expect(frame).toContain('row-250') // …down to the very last line
|
||
expect(frame).not.toContain('more lines') // and no truncation note
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
})
|
||
|
||
test('flag unset: the same 250-line output still caps at 200 + the honest note (default unchanged)', async () => {
|
||
await withFlag(undefined, async () => {
|
||
const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`)
|
||
const part: ToolPartState = {
|
||
type: 'tool',
|
||
id: 'u2',
|
||
name: 'terminal',
|
||
state: 'complete',
|
||
args: { command: 'seq 1 250' },
|
||
resultText: lines.join('\n')
|
||
}
|
||
const probe = await renderProbe(
|
||
() => (
|
||
<ThemeProvider>
|
||
<BashToolBody part={part} width={70} />
|
||
</ThemeProvider>
|
||
),
|
||
{ width: 80, height: 260 }
|
||
)
|
||
try {
|
||
const frame = await probe.waitForFrame(f => f.includes('+50 more lines'))
|
||
expect(frame).toContain('row-200')
|
||
expect(frame).not.toContain('row-201')
|
||
expect(frame).toContain('… +50 more lines')
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
})
|
||
|
||
test('store: flag set + gateway tail-cap (omittedNote) + full raw result → body derived from the raw result', async () => {
|
||
const full = Array.from({ length: 250 }, (_, i) => `full-${i + 1}`).join('\n')
|
||
const cappedTail = ['full-241', 'full-242', 'full-243'].join('\n') // what the gateway kept
|
||
const payload = {
|
||
tool_id: 'p1',
|
||
name: 'terminal',
|
||
args: { command: 'seq 1 250' },
|
||
result_text: `[showing verbose tail; omitted 240 lines / 2000 chars]\n${cappedTail}`,
|
||
result: { output: full, exit_code: 0 }
|
||
}
|
||
const partOf = (store: Store) =>
|
||
store.state.messages[store.state.messages.length - 1]?.parts?.find(
|
||
(p): p is ToolPartState => p.type === 'tool' && p.id === 'p1'
|
||
)
|
||
|
||
await withFlag('0', () => {
|
||
const store = createSessionStore()
|
||
seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload)
|
||
const part = partOf(store)
|
||
// the FULL raw result wins: longer than the capped tail, head included
|
||
expect(part?.resultText).toContain('full-1\n')
|
||
expect(part?.resultText).toContain('full-250')
|
||
expect(part?.omittedNote).toBeUndefined() // the note no longer applies
|
||
})
|
||
|
||
// flag UNSET → today's behavior: the gateway tail + the tidy omitted note
|
||
await withFlag(undefined, () => {
|
||
const store = createSessionStore()
|
||
seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload)
|
||
const part = partOf(store)
|
||
expect(part?.resultText).toBe(cappedTail)
|
||
expect(part?.resultText).not.toContain('full-1\n')
|
||
expect(part?.omittedNote).toBe('240 lines / 2000 chars')
|
||
})
|
||
|
||
// flag set but NO raw result on the wire → keep the tail + note (no crash)
|
||
await withFlag('0', () => {
|
||
const store = createSessionStore()
|
||
const withoutRaw: Record<string, unknown> = { ...payload }
|
||
delete withoutRaw['result']
|
||
seedTool(store, { tool_id: 'p1', name: 'terminal' }, withoutRaw)
|
||
const part = partOf(store)
|
||
expect(part?.resultText).toBe(cappedTail)
|
||
expect(part?.omittedNote).toBe('240 lines / 2000 chars')
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('tool-name emphasis + thought styling (feedback: undifferentiated muted rows)', () => {
|
||
const color = DEFAULT_THEME.color
|
||
|
||
test('toolNameStyle: settled name is PRIMARY (text color + bold); subtitle stays muted by the shell', () => {
|
||
expect(toolNameStyle({ failed: false, running: false }, color)).toEqual({ bold: true, fg: color.text })
|
||
expect(color.text).not.toBe(color.muted) // the emphasis is real, not a no-op
|
||
})
|
||
|
||
test('toolNameStyle: failed keeps the error coloring (it wins), running keeps its muted treatment', () => {
|
||
expect(toolNameStyle({ failed: true, running: false }, color)).toEqual({ bold: true, fg: color.error })
|
||
expect(toolNameStyle({ failed: false, running: true }, color)).toEqual({ bold: false, fg: color.muted })
|
||
})
|
||
|
||
test('reasoningLabelStyle: muted + ITALIC — a different KIND of row than a tool, never louder', () => {
|
||
expect(reasoningLabelStyle(color)).toEqual({ fg: color.muted, italic: true })
|
||
})
|
||
})
|
||
|
||
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
|
||
// parse argsText first so masked secrets never render unmasked.
|
||
|
||
test('labeled fields render the redacted args_text value, never the raw args secret', async () => {
|
||
const store = createSessionStore()
|
||
seedTool(
|
||
store,
|
||
// verbose session: tool.start carries the gateway-redacted args_text
|
||
{
|
||
tool_id: 's1',
|
||
name: 'mcp_call',
|
||
args_text: JSON.stringify({ api_key: 'sk-****', endpoint: 'v1/users' }, null, 2)
|
||
},
|
||
// tool.complete carries the raw, UNREDACTED args dict
|
||
{
|
||
tool_id: 's1',
|
||
name: 'mcp_call',
|
||
args: { api_key: 'sk-secret123', endpoint: 'v1/users' },
|
||
result_text: 'done'
|
||
}
|
||
)
|
||
|
||
const probe = await mountApp(store)
|
||
try {
|
||
await clickHeader(probe, 'mcp_call')
|
||
const expanded = await probe.waitForFrame(f => f.includes('api_key'))
|
||
expect(expanded).toContain('sk-****') // the gateway's redaction survives
|
||
expect(expanded).not.toContain('sk-secret123') // the raw secret never renders
|
||
expect(expanded).toContain('endpoint') // non-secret fields still labeled
|
||
expect(expanded).toContain('v1/users')
|
||
} finally {
|
||
probe.destroy()
|
||
}
|
||
})
|
||
|
||
test('commandOf prefers the redacted args_text parse over the raw args command', () => {
|
||
const store = createSessionStore()
|
||
seedTool(
|
||
store,
|
||
{
|
||
tool_id: 's2',
|
||
name: 'terminal',
|
||
args_text: JSON.stringify({ command: 'curl -H "Authorization: sk-****" api.test' })
|
||
},
|
||
{
|
||
tool_id: 's2',
|
||
name: 'terminal',
|
||
args: { command: 'curl -H "Authorization: sk-secret123" api.test' },
|
||
result_text: 'ok'
|
||
}
|
||
)
|
||
// Going through the real store also pins the invariant this fix relies on:
|
||
// tool.complete back-fills argsText only when ABSENT — the redacted
|
||
// tool.start args_text is never overwritten.
|
||
const last = store.state.messages[store.state.messages.length - 1]
|
||
const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 's2')
|
||
expect(part).toBeDefined()
|
||
expect(part?.argsText).toContain('sk-****')
|
||
const cmd = commandOf(part as ToolPartState)
|
||
expect(cmd).toContain('sk-****') // a masked command IS the correct display
|
||
expect(cmd).not.toContain('sk-secret123')
|
||
})
|
||
|
||
test('absent or unparseable argsText falls back to raw args (non-verbose parity)', () => {
|
||
// no argsText at all → raw args, same as the previous behavior
|
||
const bare: ToolPartState = {
|
||
type: 'tool',
|
||
id: 's3',
|
||
name: 'terminal',
|
||
state: 'complete',
|
||
args: { command: 'ls -la' }
|
||
}
|
||
expect(commandOf(bare)).toBe('ls -la')
|
||
// argsText capped mid-JSON (unparseable) → raw args still render
|
||
const capped: ToolPartState = {
|
||
type: 'tool',
|
||
id: 's4',
|
||
name: 'terminal',
|
||
state: 'complete',
|
||
args: { command: 'echo hi' },
|
||
argsText: '{"command": "echo h'
|
||
}
|
||
expect(commandOf(capped)).toBe('echo hi')
|
||
})
|
||
})
|