Files
hermes-agent/ui-opentui/src/test/tools.test.tsx
T

802 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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')
})
})