feat: headless text mode observability + skip UAT pause (#2867)
* feat: headless text mode shows tool calls + skip UAT pause in headless Text mode observability: - Tool calls always visible with summarized args (path, command, pattern) - Tool errors surfaced even in non-verbose mode - Cost updates shown periodically - Empty [status] lines suppressed (setStatus/setWidget are TUI-only) - Empty notify messages suppressed UAT pause skip: - Set GSD_HEADLESS=1 env var when spawning RPC child process - auto-dispatch checks GSD_HEADLESS and skips pauseAfterDispatch for UAT - Headless runs no longer stall waiting for human UAT verification * test: add formatProgress unit tests for headless text mode 16 tests covering tool call display, arg summarization, cost formatting, empty status suppression, and notify filtering. * ci: retrigger
This commit is contained in:
parent
36930694e4
commit
1d5590c19a
4 changed files with 221 additions and 7 deletions
|
|
@ -82,9 +82,37 @@ export function formatProgress(event: Record<string, unknown>, verbose: boolean)
|
|||
const type = String(event.type ?? '')
|
||||
|
||||
switch (type) {
|
||||
case 'tool_execution_start':
|
||||
if (verbose) return ` [tool] ${event.toolName ?? 'unknown'}`
|
||||
case 'tool_execution_start': {
|
||||
const name = String(event.toolName ?? 'unknown')
|
||||
const summary = summarizeToolArgs(name, event.args as Record<string, unknown> | undefined)
|
||||
return summary ? ` [tool] ${name} ${summary}` : ` [tool] ${name}`
|
||||
}
|
||||
|
||||
case 'tool_execution_end': {
|
||||
if (verbose) {
|
||||
const name = String(event.toolName ?? 'unknown')
|
||||
const isError = Boolean(event.isError)
|
||||
return isError ? ` [tool] ${name} ✗ error` : null
|
||||
}
|
||||
// In non-verbose, only surface errors
|
||||
if (event.isError) {
|
||||
const name = String(event.toolName ?? 'unknown')
|
||||
return ` [tool] ${name} ✗ error`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'cost_update': {
|
||||
const cumCost = event.cumulativeCost as Record<string, unknown> | undefined
|
||||
const costUsd = Number(cumCost?.costUsd ?? 0)
|
||||
if (costUsd > 0) {
|
||||
const tokens = event.tokens as Record<string, number> | undefined
|
||||
const inK = tokens ? (tokens.input / 1000).toFixed(1) : '?'
|
||||
const outK = tokens ? (tokens.output / 1000).toFixed(1) : '?'
|
||||
return ` [cost] $${costUsd.toFixed(4)} (${inK}k in / ${outK}k out)`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'agent_start':
|
||||
return '[agent] Session started'
|
||||
|
|
@ -94,11 +122,10 @@ export function formatProgress(event: Record<string, unknown>, verbose: boolean)
|
|||
|
||||
case 'extension_ui_request':
|
||||
if (event.method === 'notify') {
|
||||
return `[gsd] ${event.message ?? ''}`
|
||||
}
|
||||
if (event.method === 'setStatus') {
|
||||
return `[status] ${event.message ?? ''}`
|
||||
const msg = String(event.message ?? '')
|
||||
return msg ? `[gsd] ${msg}` : null
|
||||
}
|
||||
// setStatus / setWidget are TUI-specific — suppress in text mode
|
||||
return null
|
||||
|
||||
default:
|
||||
|
|
@ -106,6 +133,43 @@ export function formatProgress(event: Record<string, unknown>, verbose: boolean)
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a short summary from tool arguments for display.
|
||||
* Returns null if nothing useful can be summarized.
|
||||
*/
|
||||
function summarizeToolArgs(toolName: string, args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null
|
||||
|
||||
switch (toolName) {
|
||||
case 'Read':
|
||||
case 'read':
|
||||
return args.path ? String(args.path) : null
|
||||
case 'Write':
|
||||
case 'write':
|
||||
return args.path ? String(args.path) : null
|
||||
case 'Edit':
|
||||
case 'edit':
|
||||
return args.path ? String(args.path) : null
|
||||
case 'Bash':
|
||||
case 'bash': {
|
||||
const cmd = String(args.command ?? '')
|
||||
return cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd || null
|
||||
}
|
||||
case 'Grep':
|
||||
case 'grep':
|
||||
return args.pattern ? `/${args.pattern}/` + (args.path ? ` in ${args.path}` : '') : null
|
||||
case 'find':
|
||||
return args.pattern ? String(args.pattern) + (args.path ? ` in ${args.path}` : '') : null
|
||||
case 'lsp':
|
||||
return args.action ? String(args.action) + (args.symbol ? ` ${args.symbol}` : '') : null
|
||||
default: {
|
||||
// For GSD tools, show the first string arg that looks like an ID or path
|
||||
const first = Object.values(args).find(v => typeof v === 'string' && String(v).length < 80)
|
||||
return first ? String(first) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supervised Stdin Reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -344,6 +344,8 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
if (injector) {
|
||||
clientOptions.env = injector.getSecretEnvVars()
|
||||
}
|
||||
// Signal headless mode to the GSD extension (skips UAT human pause, etc.)
|
||||
clientOptions.env = { ...(clientOptions.env as Record<string, string> || {}), GSD_HEADLESS: '1' }
|
||||
// Propagate --bare to the child process
|
||||
if (options.bare) {
|
||||
clientOptions.args = [...((clientOptions.args as string[]) || []), '--bare']
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export const DISPATCH_RULES: DispatchRule[] = [
|
|||
uatContent ?? "",
|
||||
basePath,
|
||||
),
|
||||
pauseAfterDispatch: uatType !== "artifact-driven" && uatType !== "browser-executable" && uatType !== "runtime-executable",
|
||||
pauseAfterDispatch: !process.env.GSD_HEADLESS && uatType !== "artifact-driven" && uatType !== "browser-executable" && uatType !== "runtime-executable",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
148
src/tests/headless-progress.test.ts
Normal file
148
src/tests/headless-progress.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { formatProgress } from '../headless-ui.js'
|
||||
|
||||
describe('formatProgress', () => {
|
||||
describe('tool_execution_start', () => {
|
||||
it('shows tool name and summarized args', () => {
|
||||
const result = formatProgress({
|
||||
type: 'tool_execution_start',
|
||||
toolName: 'bash',
|
||||
args: { command: 'npm run build' },
|
||||
}, false)
|
||||
assert.equal(result, ' [tool] bash npm run build')
|
||||
})
|
||||
|
||||
it('shows Read with file path', () => {
|
||||
const result = formatProgress({
|
||||
type: 'tool_execution_start',
|
||||
toolName: 'Read',
|
||||
args: { path: 'src/main.ts' },
|
||||
}, false)
|
||||
assert.equal(result, ' [tool] Read src/main.ts')
|
||||
})
|
||||
|
||||
it('shows grep with pattern and path', () => {
|
||||
const result = formatProgress({
|
||||
type: 'tool_execution_start',
|
||||
toolName: 'grep',
|
||||
args: { pattern: 'TODO', path: 'src/' },
|
||||
}, false)
|
||||
assert.equal(result, ' [tool] grep /TODO/ in src/')
|
||||
})
|
||||
|
||||
it('truncates long bash commands', () => {
|
||||
const longCmd = 'a'.repeat(100)
|
||||
const result = formatProgress({
|
||||
type: 'tool_execution_start',
|
||||
toolName: 'bash',
|
||||
args: { command: longCmd },
|
||||
}, false)
|
||||
assert.ok(result!.endsWith('...'))
|
||||
assert.ok(result!.length < 100)
|
||||
})
|
||||
|
||||
it('shows tool name alone when no args', () => {
|
||||
const result = formatProgress({
|
||||
type: 'tool_execution_start',
|
||||
toolName: 'unknown_tool',
|
||||
}, false)
|
||||
assert.equal(result, ' [tool] unknown_tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool_execution_end', () => {
|
||||
it('shows error in non-verbose mode', () => {
|
||||
const result = formatProgress({
|
||||
type: 'tool_execution_end',
|
||||
toolName: 'bash',
|
||||
isError: true,
|
||||
}, false)
|
||||
assert.equal(result, ' [tool] bash ✗ error')
|
||||
})
|
||||
|
||||
it('suppresses success in non-verbose mode', () => {
|
||||
const result = formatProgress({
|
||||
type: 'tool_execution_end',
|
||||
toolName: 'bash',
|
||||
isError: false,
|
||||
}, false)
|
||||
assert.equal(result, null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost_update', () => {
|
||||
it('formats cost with token breakdown', () => {
|
||||
const result = formatProgress({
|
||||
type: 'cost_update',
|
||||
cumulativeCost: { costUsd: 0.0523 },
|
||||
tokens: { input: 4200, output: 1100 },
|
||||
}, false)
|
||||
assert.equal(result, ' [cost] $0.0523 (4.2k in / 1.1k out)')
|
||||
})
|
||||
|
||||
it('returns null for zero cost', () => {
|
||||
const result = formatProgress({
|
||||
type: 'cost_update',
|
||||
cumulativeCost: { costUsd: 0 },
|
||||
tokens: { input: 0, output: 0 },
|
||||
}, false)
|
||||
assert.equal(result, null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extension_ui_request', () => {
|
||||
it('shows notify with message', () => {
|
||||
const result = formatProgress({
|
||||
type: 'extension_ui_request',
|
||||
method: 'notify',
|
||||
message: 'Committed: fix auth',
|
||||
}, false)
|
||||
assert.equal(result, '[gsd] Committed: fix auth')
|
||||
})
|
||||
|
||||
it('suppresses empty notify', () => {
|
||||
const result = formatProgress({
|
||||
type: 'extension_ui_request',
|
||||
method: 'notify',
|
||||
message: '',
|
||||
}, false)
|
||||
assert.equal(result, null)
|
||||
})
|
||||
|
||||
it('suppresses setStatus (TUI-only)', () => {
|
||||
const result = formatProgress({
|
||||
type: 'extension_ui_request',
|
||||
method: 'setStatus',
|
||||
statusKey: 'gsd-auto',
|
||||
statusText: 'auto',
|
||||
}, false)
|
||||
assert.equal(result, null)
|
||||
})
|
||||
|
||||
it('suppresses setWidget (TUI-only)', () => {
|
||||
const result = formatProgress({
|
||||
type: 'extension_ui_request',
|
||||
method: 'setWidget',
|
||||
widgetKey: 'progress',
|
||||
}, false)
|
||||
assert.equal(result, null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent lifecycle', () => {
|
||||
it('shows agent_start', () => {
|
||||
assert.equal(formatProgress({ type: 'agent_start' }, false), '[agent] Session started')
|
||||
})
|
||||
|
||||
it('shows agent_end', () => {
|
||||
assert.equal(formatProgress({ type: 'agent_end' }, false), '[agent] Session ended')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown events', () => {
|
||||
it('returns null', () => {
|
||||
assert.equal(formatProgress({ type: 'some_random_event' }, false), null)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue