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:
TÂCHES 2026-03-27 12:13:17 -06:00 committed by GitHub
parent 36930694e4
commit 1d5590c19a
4 changed files with 221 additions and 7 deletions

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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']

View file

@ -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",
};
},
},

View 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)
})
})
})