feat: add --events flag for JSONL stream filtering (#1000)
Allow orchestrators to filter the JSONL event stream to specific event types, reducing stdout noise. The filter applies only to output — internal processing (completion detection, supervised mode, answer injection) is unaffected. - New `--events <types>` flag (comma-separated, implies `--json`) - Filter applied at stdout write point, all events still processed internally - Updated help-text and SKILL.md with examples - Tests for argument parsing and filter matching logic
This commit is contained in:
parent
fc44ea3fbe
commit
fe0f4f35e6
4 changed files with 182 additions and 2 deletions
|
|
@ -36,6 +36,7 @@ export interface HeadlessOptions {
|
|||
supervised?: boolean // supervised mode: forward interactive requests to orchestrator
|
||||
responseTimeout?: number // timeout for orchestrator response (default 30000ms)
|
||||
answers?: string // path to answers JSON file
|
||||
eventFilter?: Set<string> // filter JSONL output to specific event types
|
||||
}
|
||||
|
||||
interface ExtensionUIRequest {
|
||||
|
|
@ -103,6 +104,9 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
|||
}
|
||||
} else if (arg === '--answers' && i + 1 < args.length) {
|
||||
options.answers = args[++i]
|
||||
} else if (arg === '--events' && i + 1 < args.length) {
|
||||
options.eventFilter = new Set(args[++i].split(','))
|
||||
options.json = true // --events implies --json
|
||||
} else if (arg === '--supervised') {
|
||||
options.supervised = true
|
||||
options.json = true // supervised implies json
|
||||
|
|
@ -540,9 +544,12 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
// Answer injector: observe events for question metadata
|
||||
injector?.observeEvent(eventObj)
|
||||
|
||||
// --json mode: forward all events as JSONL to stdout
|
||||
// --json mode: forward events as JSONL to stdout (filtered if --events)
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(eventObj) + '\n')
|
||||
const eventType = String(eventObj.type ?? '')
|
||||
if (!options.eventFilter || options.eventFilter.has(eventType)) {
|
||||
process.stdout.write(JSON.stringify(eventObj) + '\n')
|
||||
}
|
||||
} else {
|
||||
// Progress output to stderr
|
||||
const line = formatProgress(eventObj, !!options.verbose)
|
||||
|
|
@ -734,6 +741,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
process.stderr.write(`[headless] Status: ${status}\n`)
|
||||
process.stderr.write(`[headless] Duration: ${duration}s\n`)
|
||||
process.stderr.write(`[headless] Events: ${totalEvents} total, ${toolCallCount} tool calls\n`)
|
||||
if (options.eventFilter) {
|
||||
process.stderr.write(`[headless] Event filter: ${[...options.eventFilter].join(', ')}\n`)
|
||||
}
|
||||
if (restartCount > 0) {
|
||||
process.stderr.write(`[headless] Restarts: ${restartCount}\n`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
' --supervised Forward interactive UI requests to orchestrator via stdout/stdin',
|
||||
' --response-timeout N Timeout (ms) for orchestrator response (default: 30000)',
|
||||
' --answers <path> Pre-supply answers and secrets (JSON file)',
|
||||
' --events <types> Filter JSONL output to specific event types (comma-separated)',
|
||||
'',
|
||||
'Commands:',
|
||||
' auto Run all queued units continuously (default)',
|
||||
|
|
@ -68,6 +69,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
' gsd headless new-milestone --context spec.md --auto Create + auto-execute',
|
||||
' gsd headless --supervised auto Supervised orchestrator mode',
|
||||
' gsd headless --answers answers.json auto With pre-supplied answers',
|
||||
' gsd headless --events agent_end,extension_ui_request auto Filtered event stream',
|
||||
' gsd headless query Instant JSON state snapshot',
|
||||
'',
|
||||
'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ gsd headless [flags] [command] [args...]
|
|||
- `--response-timeout N` — timeout for orchestrator response in supervised mode (default 30000)
|
||||
- `--max-restarts N` — auto-restart on crash with backoff (default 3, 0 to disable)
|
||||
- `--answers <path>` — pre-supply answers and secrets from JSON file
|
||||
- `--events <types>` — filter JSONL output to specific event types (comma-separated, implies `--json`)
|
||||
|
||||
**Exit codes:** 0=complete, 1=error/timeout, 2=blocked
|
||||
|
||||
|
|
@ -165,6 +166,22 @@ done
|
|||
|
||||
Event types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, `extension_ui_request`, `message_update`, `error`.
|
||||
|
||||
### Filtered Event Stream
|
||||
|
||||
Use `--events` to receive only specific event types — reduces noise for orchestrators:
|
||||
|
||||
```bash
|
||||
# Only phase-relevant events
|
||||
gsd headless --events agent_end,extension_ui_request auto 2>/dev/null
|
||||
|
||||
# Only tool execution events
|
||||
gsd headless --events tool_execution_start,tool_execution_end auto
|
||||
```
|
||||
|
||||
The filter applies only to stdout output. Internal processing (completion detection, supervised mode, answer injection) is unaffected — all events are still processed internally.
|
||||
|
||||
Available event types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, `tool_execution_update`, `extension_ui_request`, `message_start`, `message_end`, `message_update`, `turn_start`, `turn_end`.
|
||||
|
||||
## Answer Injection
|
||||
|
||||
Pre-supply answers and secrets for headless runs via `--answers`:
|
||||
|
|
|
|||
151
src/tests/headless-events.test.ts
Normal file
151
src/tests/headless-events.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Tests for `--events` flag — JSONL event stream filtering.
|
||||
*
|
||||
* Validates argument parsing and the event filter logic used by
|
||||
* the headless orchestrator to reduce stdout noise for orchestrators.
|
||||
*
|
||||
* Uses extracted parsing logic (mirrors headless.ts) to avoid
|
||||
* transitive @gsd/native import that breaks in test environment.
|
||||
*/
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// ─── Extracted parsing logic (mirrors headless.ts) ─────────────────────────
|
||||
|
||||
interface HeadlessOptions {
|
||||
timeout: number
|
||||
json: boolean
|
||||
model?: string
|
||||
command: string
|
||||
commandArgs: string[]
|
||||
context?: string
|
||||
contextText?: string
|
||||
auto?: boolean
|
||||
verbose?: boolean
|
||||
maxRestarts?: number
|
||||
supervised?: boolean
|
||||
responseTimeout?: number
|
||||
answers?: string
|
||||
eventFilter?: Set<string>
|
||||
}
|
||||
|
||||
function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
||||
const options: HeadlessOptions = {
|
||||
timeout: 300_000,
|
||||
json: false,
|
||||
command: 'auto',
|
||||
commandArgs: [],
|
||||
}
|
||||
|
||||
const args = argv.slice(2)
|
||||
let positionalStarted = false
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (arg === 'headless') continue
|
||||
|
||||
if (!positionalStarted && arg.startsWith('--')) {
|
||||
if (arg === '--timeout' && i + 1 < args.length) {
|
||||
options.timeout = parseInt(args[++i], 10)
|
||||
} else if (arg === '--json') {
|
||||
options.json = true
|
||||
} else if (arg === '--model' && i + 1 < args.length) {
|
||||
options.model = args[++i]
|
||||
} else if (arg === '--context' && i + 1 < args.length) {
|
||||
options.context = args[++i]
|
||||
} else if (arg === '--context-text' && i + 1 < args.length) {
|
||||
options.contextText = args[++i]
|
||||
} else if (arg === '--auto') {
|
||||
options.auto = true
|
||||
} else if (arg === '--verbose') {
|
||||
options.verbose = true
|
||||
} else if (arg === '--max-restarts' && i + 1 < args.length) {
|
||||
options.maxRestarts = parseInt(args[++i], 10)
|
||||
} else if (arg === '--answers' && i + 1 < args.length) {
|
||||
options.answers = args[++i]
|
||||
} else if (arg === '--events' && i + 1 < args.length) {
|
||||
options.eventFilter = new Set(args[++i].split(','))
|
||||
options.json = true
|
||||
} else if (arg === '--supervised') {
|
||||
options.supervised = true
|
||||
options.json = true
|
||||
} else if (arg === '--response-timeout' && i + 1 < args.length) {
|
||||
options.responseTimeout = parseInt(args[++i], 10)
|
||||
}
|
||||
} else if (!positionalStarted) {
|
||||
positionalStarted = true
|
||||
options.command = arg
|
||||
} else {
|
||||
options.commandArgs.push(arg)
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// ─── parseHeadlessArgs: --events flag ──────────────────────────────────────
|
||||
|
||||
test('--events parses comma-separated event types into a Set', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', 'agent_end,extension_ui_request', 'auto'])
|
||||
assert.ok(opts.eventFilter instanceof Set)
|
||||
assert.equal(opts.eventFilter!.size, 2)
|
||||
assert.ok(opts.eventFilter!.has('agent_end'))
|
||||
assert.ok(opts.eventFilter!.has('extension_ui_request'))
|
||||
})
|
||||
|
||||
test('--events implies --json', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', 'agent_end', 'auto'])
|
||||
assert.equal(opts.json, true)
|
||||
})
|
||||
|
||||
test('--events with single type', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', 'agent_end', 'auto'])
|
||||
assert.equal(opts.eventFilter!.size, 1)
|
||||
assert.ok(opts.eventFilter!.has('agent_end'))
|
||||
})
|
||||
|
||||
test('no --events flag means no filter', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--json', 'auto'])
|
||||
assert.equal(opts.eventFilter, undefined)
|
||||
})
|
||||
|
||||
test('--events with all common types', () => {
|
||||
const types = 'agent_start,agent_end,tool_execution_start,tool_execution_end,extension_ui_request'
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--events', types, 'auto'])
|
||||
assert.equal(opts.eventFilter!.size, 5)
|
||||
})
|
||||
|
||||
test('--events combined with other flags', () => {
|
||||
const opts = parseHeadlessArgs(['node', 'gsd', 'headless', '--timeout', '60000', '--events', 'agent_end', '--verbose', 'next'])
|
||||
assert.equal(opts.timeout, 60000)
|
||||
assert.equal(opts.verbose, true)
|
||||
assert.equal(opts.command, 'next')
|
||||
assert.ok(opts.eventFilter!.has('agent_end'))
|
||||
assert.equal(opts.json, true)
|
||||
})
|
||||
|
||||
// ─── Event filter matching logic ───────────────────────────────────────────
|
||||
|
||||
test('filter allows matching event types', () => {
|
||||
const filter = new Set(['agent_end', 'extension_ui_request'])
|
||||
assert.ok(filter.has('agent_end'))
|
||||
assert.ok(filter.has('extension_ui_request'))
|
||||
assert.ok(!filter.has('message_update'))
|
||||
assert.ok(!filter.has('tool_execution_start'))
|
||||
})
|
||||
|
||||
test('no filter allows all event types (undefined check)', () => {
|
||||
const filter: Set<string> | undefined = undefined
|
||||
const shouldEmit = (type: string) => !filter || filter.has(type)
|
||||
assert.ok(shouldEmit('agent_end'))
|
||||
assert.ok(shouldEmit('message_update'))
|
||||
assert.ok(shouldEmit('tool_execution_start'))
|
||||
})
|
||||
|
||||
test('empty filter blocks all events', () => {
|
||||
const filter = new Set<string>()
|
||||
const shouldEmit = (type: string) => !filter || filter.has(type)
|
||||
assert.ok(!shouldEmit('agent_end'))
|
||||
assert.ok(!shouldEmit('message_update'))
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue