diff --git a/src/headless-ui.ts b/src/headless-ui.ts index 387be26ca..7beea6bef 100644 --- a/src/headless-ui.ts +++ b/src/headless-ui.ts @@ -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 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, 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 diff --git a/src/headless.ts b/src/headless.ts index f332dbe89..31fe897dd 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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>() @@ -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`) diff --git a/src/tests/headless-v2-migration.test.ts b/src/tests/headless-v2-migration.test.ts new file mode 100644 index 000000000..cea747f40 --- /dev/null +++ b/src/tests/headless-v2-migration.test.ts @@ -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): 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): 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, + 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']) +})