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:
parent
9823fd2d2d
commit
3be38e3794
3 changed files with 531 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
462
src/tests/headless-v2-migration.test.ts
Normal file
462
src/tests/headless-v2-migration.test.ts
Normal 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'])
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue