opentui(v6): bash tool renderer — command + full output
This commit is contained in:
@@ -166,6 +166,40 @@ describe('session store — ordered parts (Phase 2b)', () => {
|
||||
expect(tool.argsText).toContain('"query"') // stringified fallback still kept
|
||||
})
|
||||
|
||||
test('derives resultText from the raw `result` when result_text is absent (non-verbose sessions)', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 'nv', name: 'terminal' } })
|
||||
// non-verbose: no result_text — only the raw envelope-string `result`
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: { tool_id: 'nv', result: '{"output":"hi there\\nline two","exit_code":0,"error":null}' }
|
||||
})
|
||||
const tool = store.state.messages.at(-1)!.parts![0]!
|
||||
if (tool.type !== 'tool') throw new Error('expected a tool part')
|
||||
expect(tool.resultText).toBe('hi there\nline two') // envelope stripped, same pipeline
|
||||
expect(tool.lineCount).toBe(2)
|
||||
})
|
||||
|
||||
test('an object `result` is unwrapped too; result_text keeps precedence when present', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 'o1', name: 'terminal' } })
|
||||
store.apply({ type: 'tool.complete', payload: { tool_id: 'o1', result: { output: 'obj out', exit_code: 0 } } })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 'o2', name: 'terminal' } })
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: { tool_id: 'o2', result: 'raw fallback', result_text: 'verbose text' }
|
||||
})
|
||||
|
||||
const parts = store.state.messages.at(-1)!.parts!
|
||||
const first = parts[0]!
|
||||
const second = parts[1]!
|
||||
if (first.type !== 'tool' || second.type !== 'tool') throw new Error('expected tool parts')
|
||||
expect(first.resultText).toBe('obj out') // object envelope → its output
|
||||
expect(second.resultText).toBe('verbose text') // result_text still wins when sent
|
||||
})
|
||||
|
||||
test('setCatalog maps the loose startup.catalog response defensively (item 9)', () => {
|
||||
const store = createSessionStore()
|
||||
store.setCatalog({
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* Tool renderer tests (Epic 2.2). Headless frames through the real App tree:
|
||||
* the registry's default renderer turns args into LABELED FIELDS — the
|
||||
* 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 — and delegate_task carries the
|
||||
* Ink-parity "(/agents to monitor)" hint. Expansion goes through the REAL
|
||||
* mouse path: mockMouse clicks the header row (found by scanning the frame).
|
||||
* 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 } from 'vitest'
|
||||
|
||||
import { createSessionStore } from '../logic/store.ts'
|
||||
import { createSessionStore, type ToolPartState } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { BashToolBody } from '../view/tools/bashTool.tsx'
|
||||
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
||||
|
||||
type Store = ReturnType<typeof createSessionStore>
|
||||
@@ -116,3 +120,117 @@ describe('tool renderer registry — labeled-args default (Epic 2.2)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user