feat: Migrated headless orchestrator to use execution_complete events,…

- "src/headless.ts"
- "src/headless-ui.ts"
- "src/tests/headless-v2-migration.test.ts"

GSD-Task: S06/T02
This commit is contained in:
Lex Christopherson 2026-03-26 18:02:56 -06:00
parent 9823fd2d2d
commit 3be38e3794
3 changed files with 531 additions and 40 deletions

View file

@ -8,7 +8,7 @@
import type { Readable } from 'node:stream'
import { RpcClient, attachJsonlLineReader, serializeJsonLine } from '@gsd/pi-coding-agent'
import { RpcClient, attachJsonlLineReader } from '@gsd/pi-coding-agent'
// ---------------------------------------------------------------------------
// Types
@ -34,10 +34,9 @@ export type { ExtensionUIRequest }
export function handleExtensionUIRequest(
event: ExtensionUIRequest,
writeToStdin: (data: string) => void,
client: RpcClient,
): void {
const { id, method } = event
let response: Record<string, unknown>
switch (method) {
case 'select': {
@ -49,32 +48,30 @@ export function handleExtensionUIRequest(
const forceOption = event.options.find(o => o.toLowerCase().includes('force start'))
if (forceOption) selected = forceOption
}
response = { type: 'extension_ui_response', id, value: selected }
client.sendUIResponse(id, { value: selected })
break
}
case 'confirm':
response = { type: 'extension_ui_response', id, confirmed: true }
client.sendUIResponse(id, { confirmed: true })
break
case 'input':
response = { type: 'extension_ui_response', id, value: '' }
client.sendUIResponse(id, { value: '' })
break
case 'editor':
response = { type: 'extension_ui_response', id, value: event.prefill ?? '' }
client.sendUIResponse(id, { value: event.prefill ?? '' })
break
case 'notify':
case 'setStatus':
case 'setWidget':
case 'setTitle':
case 'set_editor_text':
response = { type: 'extension_ui_response', id, value: '' }
client.sendUIResponse(id, { value: '' })
break
default:
process.stderr.write(`[headless] Warning: unknown extension_ui_request method "${method}", cancelling\n`)
response = { type: 'extension_ui_response', id, cancelled: true }
client.sendUIResponse(id, { cancelled: true })
break
}
writeToStdin(serializeJsonLine(response))
}
// ---------------------------------------------------------------------------
@ -114,7 +111,6 @@ export function formatProgress(event: Record<string, unknown>, verbose: boolean)
// ---------------------------------------------------------------------------
export function startSupervisedStdinReader(
stdinWriter: (data: string) => void,
client: RpcClient,
onResponse: (id: string) => void,
): () => void {
@ -130,12 +126,17 @@ export function startSupervisedStdinReader(
const type = String(msg.type ?? '')
switch (type) {
case 'extension_ui_response':
stdinWriter(line + '\n')
if (typeof msg.id === 'string') {
onResponse(msg.id)
case 'extension_ui_response': {
const id = String(msg.id ?? '')
const value = msg.value !== undefined ? String(msg.value) : undefined
const confirmed = typeof msg.confirmed === 'boolean' ? msg.confirmed : undefined
const cancelled = typeof msg.cancelled === 'boolean' ? msg.cancelled : undefined
client.sendUIResponse(id, { value, confirmed, cancelled })
if (id) {
onResponse(id)
}
break
}
case 'prompt':
client.prompt(String(msg.message ?? ''))
break

View file

@ -345,8 +345,11 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
if (recentEvents.length > 20) recentEvents.shift()
}
// Stdin writer for sending extension_ui_response to child
let stdinWriter: ((data: string) => void) | null = null
// Client started flag — replaces old stdinWriter null-check
let clientStarted = false
// Adapter for AnswerInjector — wraps client.sendUIResponse in a writeToStdin-compatible callback
// Initialized after client.start(); events won't fire before then
let injectorStdinAdapter: (data: string) => void = () => {}
// Supervised mode state
const pendingResponseTimers = new Map<string, ReturnType<typeof setTimeout>>()
@ -413,8 +416,18 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
if (line) process.stderr.write(line + '\n')
}
// Handle execution_complete (v2 structured completion)
if (eventObj.type === 'execution_complete' && !completed) {
completed = true
const status = String(eventObj.status ?? 'success')
exitCode = mapStatusToExitCode(status)
if (eventObj.status === 'blocked') blocked = true
resolveCompletion()
return
}
// Handle extension_ui_request
if (eventObj.type === 'extension_ui_request' && stdinWriter) {
if (eventObj.type === 'extension_ui_request' && clientStarted) {
// Check for terminal notification before auto-responding
if (isBlockedNotification(eventObj)) {
blocked = true
@ -431,7 +444,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
// Answer injection: try to handle with pre-supplied answers before supervised/auto
if (injector && !FIRE_AND_FORGET_METHODS.has(String(eventObj.method ?? ''))) {
if (injector.tryHandle(eventObj, stdinWriter)) {
if (injector.tryHandle(eventObj, injectorStdinAdapter)) {
if (completed) {
exitCode = blocked ? EXIT_BLOCKED : EXIT_SUCCESS
resolveCompletion()
@ -449,12 +462,12 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
const eventId = String(eventObj.id ?? '')
const timer = setTimeout(() => {
pendingResponseTimers.delete(eventId)
handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter!)
handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, client)
process.stdout.write(JSON.stringify({ type: 'supervised_timeout', id: eventId, method }) + '\n')
}, responseTimeout)
pendingResponseTimers.set(eventId, timer)
} else {
handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter)
handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, client)
}
// If we detected a terminal notification, resolve after responding
@ -499,22 +512,33 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
process.exit(1)
}
// Access stdin writer from the internal process
const internalProcess = (client as any).process as ChildProcess
if (!internalProcess?.stdin) {
process.stderr.write('[headless] Error: Cannot access child process stdin\n')
await client.stop()
if (timeoutTimer) clearTimeout(timeoutTimer)
process.exit(1)
// v2 protocol negotiation — attempt init for structured completion events
let v2Enabled = false
try {
await client.init({ clientId: 'gsd-headless' })
v2Enabled = true
} catch {
process.stderr.write('[headless] Warning: v2 init failed, falling back to v1 string-matching\n')
}
stdinWriter = (data: string) => {
internalProcess.stdin!.write(data)
clientStarted = true
// Build injector adapter — wraps client.sendUIResponse for AnswerInjector's writeToStdin interface
injectorStdinAdapter = (data: string) => {
try {
const parsed = JSON.parse(data.trim())
if (parsed.type === 'extension_ui_response' && parsed.id) {
const { id, value, values, confirmed, cancelled } = parsed
client.sendUIResponse(id, { value, values, confirmed, cancelled })
}
} catch {
process.stderr.write('[headless] Warning: injector adapter received unparseable data\n')
}
}
// Start supervised stdin reader for orchestrator commands
if (options.supervised) {
stopSupervisedReader = startSupervisedStdinReader(stdinWriter, client, (id) => {
stopSupervisedReader = startSupervisedStdinReader(client, (id) => {
const timer = pendingResponseTimers.get(id)
if (timer) {
clearTimeout(timer)
@ -525,14 +549,18 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
process.stdin.resume()
}
// Detect child process crash
internalProcess.on('exit', (code) => {
if (!completed) {
const msg = `[headless] Child process exited unexpectedly with code ${code ?? 'null'}\n`
process.stderr.write(msg)
exitCode = EXIT_ERROR
resolveCompletion()
} })
// Detect child process crash (read-only exit event subscription — not stdin access)
const internalProcess = (client as any).process as ChildProcess
if (internalProcess) {
internalProcess.on('exit', (code) => {
if (!completed) {
const msg = `[headless] Child process exited unexpectedly with code ${code ?? 'null'}\n`
process.stderr.write(msg)
exitCode = EXIT_ERROR
resolveCompletion()
}
})
}
if (!options.json) {
process.stderr.write(`[headless] Running /gsd ${options.command}${options.commandArgs.length > 0 ? ' ' + options.commandArgs.join(' ') : ''}...\n`)

View file

@ -0,0 +1,462 @@
/**
* Tests for headless v2 migration execution_complete handling,
* sendUIResponse-based auto-response, and v1 fallback behavior.
*
* Uses extracted logic mirrors to avoid importing modules with native
* dependencies (same pattern as headless-events.test.ts and headless-detection.test.ts).
*/
import test from 'node:test'
import assert from 'node:assert/strict'
// ─── Extracted exit codes (mirrors headless-events.ts) ──────────────────────
const EXIT_SUCCESS = 0
const EXIT_ERROR = 1
const EXIT_BLOCKED = 10
function mapStatusToExitCode(status: string): number {
switch (status) {
case 'success':
case 'complete':
return EXIT_SUCCESS
case 'error':
case 'timeout':
return EXIT_ERROR
case 'blocked':
return EXIT_BLOCKED
case 'cancelled':
return 11
default:
return EXIT_ERROR
}
}
// ─── Extracted terminal detection (mirrors headless-events.ts) ──────────────
const TERMINAL_PREFIXES = ['auto-mode stopped', 'step-mode stopped']
function isTerminalNotification(event: Record<string, unknown>): boolean {
if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false
const message = String(event.message ?? '').toLowerCase()
return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix))
}
function isBlockedNotification(event: Record<string, unknown>): boolean {
if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false
const message = String(event.message ?? '').toLowerCase()
return message.includes('blocked:')
}
// ─── Mock RpcClient ─────────────────────────────────────────────────────────
interface SendUICall {
id: string
response: { value?: string; values?: string[]; confirmed?: boolean; cancelled?: boolean }
}
class MockRpcClient {
sendUICalls: SendUICall[] = []
initCalled = false
initShouldFail = false
sendUIResponse(id: string, response: { value?: string; values?: string[]; confirmed?: boolean; cancelled?: boolean }): void {
this.sendUICalls.push({ id, response })
}
async init(_options?: { clientId?: string }): Promise<{ protocolVersion: number }> {
this.initCalled = true
if (this.initShouldFail) {
throw new Error('v2 init not supported')
}
return { protocolVersion: 2 }
}
}
// ─── Extracted handleExtensionUIRequest (mirrors headless-ui.ts) ────────────
interface ExtensionUIRequest {
type: 'extension_ui_request'
id: string
method: string
title?: string
options?: string[]
message?: string
prefill?: string
[key: string]: unknown
}
function handleExtensionUIRequest(
event: ExtensionUIRequest,
client: MockRpcClient,
): void {
const { id, method } = event
switch (method) {
case 'select': {
const title = String(event.title ?? '')
let selected = event.options?.[0] ?? ''
if (title.includes('Auto-mode is running') && event.options) {
const forceOption = event.options.find(o => o.toLowerCase().includes('force start'))
if (forceOption) selected = forceOption
}
client.sendUIResponse(id, { value: selected })
break
}
case 'confirm':
client.sendUIResponse(id, { confirmed: true })
break
case 'input':
client.sendUIResponse(id, { value: '' })
break
case 'editor':
client.sendUIResponse(id, { value: event.prefill ?? '' })
break
case 'notify':
case 'setStatus':
case 'setWidget':
case 'setTitle':
case 'set_editor_text':
client.sendUIResponse(id, { value: '' })
break
default:
client.sendUIResponse(id, { cancelled: true })
break
}
}
// ─── Simulated event handler (mirrors headless.ts event handler logic) ──────
interface EventHandlerState {
completed: boolean
blocked: boolean
exitCode: number
v2Enabled: boolean
}
function handleEvent(
eventObj: Record<string, unknown>,
state: EventHandlerState,
client: MockRpcClient,
): void {
// execution_complete (v2 structured completion)
if (eventObj.type === 'execution_complete' && !state.completed) {
state.completed = true
const status = String(eventObj.status ?? 'success')
state.exitCode = mapStatusToExitCode(status)
if (eventObj.status === 'blocked') state.blocked = true
return
}
// extension_ui_request (v1 fallback + UI responses)
if (eventObj.type === 'extension_ui_request') {
if (isBlockedNotification(eventObj)) {
state.blocked = true
}
if (isTerminalNotification(eventObj)) {
state.completed = true
}
handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, client)
if (state.completed) {
state.exitCode = state.blocked ? EXIT_BLOCKED : EXIT_SUCCESS
return
}
}
}
// ─── execution_complete event handling ──────────────────────────────────────
test('execution_complete with status success triggers completion with EXIT_SUCCESS', () => {
const client = new MockRpcClient()
const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true }
handleEvent({ type: 'execution_complete', status: 'success' }, state, client)
assert.equal(state.completed, true)
assert.equal(state.exitCode, EXIT_SUCCESS)
assert.equal(state.blocked, false)
})
test('execution_complete with status blocked sets blocked flag and EXIT_BLOCKED', () => {
const client = new MockRpcClient()
const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true }
handleEvent({ type: 'execution_complete', status: 'blocked' }, state, client)
assert.equal(state.completed, true)
assert.equal(state.blocked, true)
assert.equal(state.exitCode, EXIT_BLOCKED)
})
test('execution_complete with status error maps to EXIT_ERROR', () => {
const client = new MockRpcClient()
const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true }
handleEvent({ type: 'execution_complete', status: 'error' }, state, client)
assert.equal(state.completed, true)
assert.equal(state.exitCode, EXIT_ERROR)
})
test('execution_complete with missing status defaults to success', () => {
const client = new MockRpcClient()
const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true }
handleEvent({ type: 'execution_complete' }, state, client)
assert.equal(state.completed, true)
assert.equal(state.exitCode, EXIT_SUCCESS)
})
test('execution_complete ignored if already completed', () => {
const client = new MockRpcClient()
const state: EventHandlerState = { completed: true, blocked: false, exitCode: EXIT_SUCCESS, v2Enabled: true }
handleEvent({ type: 'execution_complete', status: 'error' }, state, client)
// Should not change exitCode because already completed
assert.equal(state.exitCode, EXIT_SUCCESS)
})
// ─── v1 string-matching fallback ────────────────────────────────────────────
test('v1 fallback: terminal notification still triggers completion', () => {
const client = new MockRpcClient()
const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: false }
handleEvent(
{ type: 'extension_ui_request', method: 'notify', id: 'n1', message: 'Auto-mode stopped — all slices complete' },
state,
client,
)
assert.equal(state.completed, true)
assert.equal(state.exitCode, EXIT_SUCCESS)
})
test('v1 fallback: blocked notification sets blocked flag', () => {
const client = new MockRpcClient()
const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: false }
handleEvent(
{ type: 'extension_ui_request', method: 'notify', id: 'n1', message: 'Auto-mode stopped (Blocked: plan invalid)' },
state,
client,
)
assert.equal(state.completed, true)
assert.equal(state.blocked, true)
assert.equal(state.exitCode, EXIT_BLOCKED)
})
test('string-matching fallback works when execution_complete never received', () => {
const client = new MockRpcClient()
const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: false }
// Simulate a normal session without execution_complete
handleEvent({ type: 'extension_ui_request', method: 'select', id: 'q1', options: ['option1'] }, state, client)
assert.equal(state.completed, false)
handleEvent(
{ type: 'extension_ui_request', method: 'notify', id: 'n1', message: 'Step-mode stopped — done' },
state,
client,
)
assert.equal(state.completed, true)
assert.equal(state.exitCode, EXIT_SUCCESS)
})
// ─── handleExtensionUIRequest uses client.sendUIResponse ────────────────────
test('handleExtensionUIRequest select calls sendUIResponse with value', () => {
const client = new MockRpcClient()
handleExtensionUIRequest(
{ type: 'extension_ui_request', id: 'sel1', method: 'select', options: ['option-a', 'option-b'] },
client,
)
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'sel1')
assert.equal(client.sendUICalls[0].response.value, 'option-a')
})
test('handleExtensionUIRequest confirm calls sendUIResponse with confirmed', () => {
const client = new MockRpcClient()
handleExtensionUIRequest(
{ type: 'extension_ui_request', id: 'conf1', method: 'confirm' },
client,
)
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'conf1')
assert.equal(client.sendUICalls[0].response.confirmed, true)
})
test('handleExtensionUIRequest input calls sendUIResponse with empty value', () => {
const client = new MockRpcClient()
handleExtensionUIRequest(
{ type: 'extension_ui_request', id: 'inp1', method: 'input' },
client,
)
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'inp1')
assert.equal(client.sendUICalls[0].response.value, '')
})
test('handleExtensionUIRequest notify calls sendUIResponse with empty value', () => {
const client = new MockRpcClient()
handleExtensionUIRequest(
{ type: 'extension_ui_request', id: 'not1', method: 'notify', message: 'Task complete' },
client,
)
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'not1')
assert.equal(client.sendUICalls[0].response.value, '')
})
test('handleExtensionUIRequest editor calls sendUIResponse with prefill', () => {
const client = new MockRpcClient()
handleExtensionUIRequest(
{ type: 'extension_ui_request', id: 'ed1', method: 'editor', prefill: 'initial text' },
client,
)
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'ed1')
assert.equal(client.sendUICalls[0].response.value, 'initial text')
})
test('handleExtensionUIRequest unknown method calls sendUIResponse with cancelled', () => {
const client = new MockRpcClient()
handleExtensionUIRequest(
{ type: 'extension_ui_request', id: 'unk1', method: 'unknown_method' },
client,
)
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'unk1')
assert.equal(client.sendUICalls[0].response.cancelled, true)
})
// ─── supervised stdin reader forwarding via sendUIResponse ──────────────────
test('extension_ui_response forwarding extracts fields and calls sendUIResponse', () => {
// Simulates what startSupervisedStdinReader does with a parsed message
const client = new MockRpcClient()
const msg = { type: 'extension_ui_response', id: 'resp1', value: 'chosen option', confirmed: undefined, cancelled: undefined }
const id = String(msg.id ?? '')
const value = msg.value !== undefined ? String(msg.value) : undefined
const confirmed = typeof msg.confirmed === 'boolean' ? msg.confirmed : undefined
const cancelled = typeof msg.cancelled === 'boolean' ? msg.cancelled : undefined
client.sendUIResponse(id, { value, confirmed, cancelled })
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'resp1')
assert.equal(client.sendUICalls[0].response.value, 'chosen option')
assert.equal(client.sendUICalls[0].response.confirmed, undefined)
assert.equal(client.sendUICalls[0].response.cancelled, undefined)
})
test('extension_ui_response with confirmed=true forwards correctly', () => {
const client = new MockRpcClient()
const msg = { type: 'extension_ui_response', id: 'resp2', confirmed: true }
const id = String(msg.id ?? '')
const confirmed = typeof msg.confirmed === 'boolean' ? msg.confirmed : undefined
client.sendUIResponse(id, { confirmed })
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'resp2')
assert.equal(client.sendUICalls[0].response.confirmed, true)
})
// ─── v2 init negotiation ────────────────────────────────────────────────────
test('v2 init success sets v2Enabled', async () => {
const client = new MockRpcClient()
let v2Enabled = false
try {
await client.init({ clientId: 'gsd-headless' })
v2Enabled = true
} catch {
// fall back to v1
}
assert.equal(client.initCalled, true)
assert.equal(v2Enabled, true)
})
test('v2 init failure falls back gracefully (v1 mode)', async () => {
const client = new MockRpcClient()
client.initShouldFail = true
let v2Enabled = false
try {
await client.init({ clientId: 'gsd-headless' })
v2Enabled = true
} catch {
// fall back to v1 — this is expected
}
assert.equal(client.initCalled, true)
assert.equal(v2Enabled, false)
})
// ─── injector adapter ───────────────────────────────────────────────────────
test('injector adapter parses serialized JSONL and calls sendUIResponse', () => {
const client = new MockRpcClient()
// Simulate what the adapter does
const data = '{"type":"extension_ui_response","id":"inj1","value":"selected"}\n'
const parsed = JSON.parse(data.trim())
if (parsed.type === 'extension_ui_response' && parsed.id) {
const { id, value, values, confirmed, cancelled } = parsed
client.sendUIResponse(id, { value, values, confirmed, cancelled })
}
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'inj1')
assert.equal(client.sendUICalls[0].response.value, 'selected')
})
test('injector adapter handles cancelled response', () => {
const client = new MockRpcClient()
const data = '{"type":"extension_ui_response","id":"inj2","cancelled":true}\n'
const parsed = JSON.parse(data.trim())
if (parsed.type === 'extension_ui_response' && parsed.id) {
const { id, value, values, confirmed, cancelled } = parsed
client.sendUIResponse(id, { value, values, confirmed, cancelled })
}
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'inj2')
assert.equal(client.sendUICalls[0].response.cancelled, true)
})
test('injector adapter handles multi-select values', () => {
const client = new MockRpcClient()
const data = '{"type":"extension_ui_response","id":"inj3","values":["a","b"]}\n'
const parsed = JSON.parse(data.trim())
if (parsed.type === 'extension_ui_response' && parsed.id) {
const { id, value, values, confirmed, cancelled } = parsed
client.sendUIResponse(id, { value, values, confirmed, cancelled })
}
assert.equal(client.sendUICalls.length, 1)
assert.equal(client.sendUICalls[0].id, 'inj3')
assert.deepEqual(client.sendUICalls[0].response.values, ['a', 'b'])
})