diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index 1419be9..76e29e5 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -387,6 +387,34 @@ describe('interactiveMode', () => { ); }); + it('should abort in-flight provider call on SIGINT during initial input', async () => { + mockGetProvider.mockReturnValue({ + setup: () => ({ + call: vi.fn((_prompt: string, options: { abortSignal?: AbortSignal }) => { + return new Promise((resolve) => { + options.abortSignal?.addEventListener('abort', () => { + resolve({ + persona: 'interactive', + status: 'error', + content: 'aborted', + timestamp: new Date(), + }); + }, { once: true }); + }); + }), + }), + } as unknown as ReturnType); + + const promise = interactiveMode('/project', 'trigger'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const listeners = process.rawListeners('SIGINT') as Array<() => void>; + listeners[listeners.length - 1]?.(); + + const result = await promise; + expect(result.action).toBe('cancel'); + }); + it('should use saved sessionId from initializeSession when no sessionId parameter is given', async () => { // Given setupRawStdin(toRawInputs(['hello', '/cancel'])); diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index 58c0e54..3081682 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -245,4 +245,115 @@ describe('OpenCodeClient stream cleanup', () => { expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + + it('should fail fast when question.asked is received without handler', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'question.asked', + properties: { + id: 'q-1', + sessionID: 'session-4', + questions: [ + { + question: 'Select one', + header: 'Question', + options: [{ label: 'A', description: 'A desc' }], + }, + ], + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-4' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const questionReject = vi.fn().mockResolvedValue({ data: true }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + question: { reject: questionReject, reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('no question handler'); + expect(questionReject).toHaveBeenCalledWith({ + requestID: 'q-1', + directory: '/tmp', + }); + }); + + it('should answer question.asked when handler is configured', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'question.asked', + properties: { + id: 'q-2', + sessionID: 'session-5', + questions: [ + { + question: 'Select one', + header: 'Question', + options: [{ label: 'A', description: 'A desc' }], + }, + ], + }, + }, + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-5', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-5' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const questionReply = vi.fn().mockResolvedValue({ data: true }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + question: { reject: vi.fn(), reply: questionReply }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + onAskUserQuestion: async () => ({ Question: 'A' }), + }); + + expect(result.status).toBe('done'); + expect(questionReply).toHaveBeenCalledWith({ + requestID: 'q-2', + directory: '/tmp', + answers: [['A']], + }); + }); }); diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 1819ceb..156862f 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -22,6 +22,7 @@ import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; import { readMultilineInput } from './lineEditor.js'; +import { EXIT_SIGINT } from '../../shared/exitCodes.js'; import { type PieceContext, type InteractiveModeResult, @@ -97,6 +98,21 @@ export async function callAIWithRetry( ctx: SessionContext, ): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> { const display = new StreamDisplay('assistant', isQuietMode()); + const abortController = new AbortController(); + let sigintCount = 0; + const onSigInt = (): void => { + sigintCount += 1; + if (sigintCount === 1) { + blankLine(); + info(getLabel('piece.sigintGraceful', ctx.lang)); + abortController.abort(); + return; + } + blankLine(); + error(getLabel('piece.sigintForce', ctx.lang)); + process.exit(EXIT_SIGINT); + }; + process.on('SIGINT', onSigInt); let { sessionId } = ctx; try { @@ -106,6 +122,7 @@ export async function callAIWithRetry( model: ctx.model, sessionId, allowedTools, + abortSignal: abortController.signal, onStream: display.createHandler(), }); display.flush(); @@ -121,6 +138,7 @@ export async function callAIWithRetry( model: ctx.model, sessionId: undefined, allowedTools, + abortSignal: abortController.signal, onStream: retryDisplay.createHandler(), }); retryDisplay.flush(); @@ -148,6 +166,8 @@ export async function callAIWithRetry( error(msg); blankLine(); return { result: null, sessionId }; + } finally { + process.removeListener('SIGINT', onSigInt); } } diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts index 421bb6f..f7c0e82 100644 --- a/src/infra/opencode/OpenCodeStreamHandler.ts +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -87,6 +87,23 @@ export interface OpenCodePermissionAskedEvent { }; } +export interface OpenCodeQuestionAskedEvent { + type: 'question.asked'; + properties: { + id: string; + sessionID: string; + questions: Array<{ + question: string; + header: string; + options: Array<{ + label: string; + description: string; + }>; + multiple?: boolean; + }>; + }; +} + export type OpenCodeStreamEvent = | OpenCodeMessagePartUpdatedEvent | OpenCodeMessageUpdatedEvent @@ -94,6 +111,7 @@ export type OpenCodeStreamEvent = | OpenCodeSessionIdleEvent | OpenCodeSessionErrorEvent | OpenCodePermissionAskedEvent + | OpenCodeQuestionAskedEvent | { type: string; properties: Record }; /** Tracking state for stream offsets during a single OpenCode session */ diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 2161c4d..3d266fd 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -83,6 +83,60 @@ function stripPromptEcho( return chunk; } +type OpenCodeQuestionOption = { + label: string; + description: string; +}; + +type OpenCodeQuestionInfo = { + question: string; + header: string; + options: OpenCodeQuestionOption[]; + multiple?: boolean; +}; + +type OpenCodeQuestionAskedProperties = { + id: string; + sessionID: string; + questions: OpenCodeQuestionInfo[]; +}; + +function toQuestionInput(props: OpenCodeQuestionAskedProperties): { + questions: Array<{ + question: string; + header?: string; + options?: Array<{ + label: string; + description?: string; + }>; + multiSelect?: boolean; + }>; +} { + return { + questions: props.questions.map((item) => ({ + question: item.question, + header: item.header, + options: item.options.map((opt) => ({ + label: opt.label, + description: opt.description, + })), + multiSelect: item.multiple, + })), + }; +} + +function toQuestionAnswers( + props: OpenCodeQuestionAskedProperties, + answers: Record, +): Array> { + return props.questions.map((item) => { + const key = item.header || item.question; + const value = answers[key]; + if (!value) return []; + return [value]; + }); +} + async function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); @@ -205,6 +259,7 @@ export class OpenCodeClient { const config = { model: fullModel, small_model: fullModel, + permission: { question: 'deny' as const }, ...(options.opencodeApiKey ? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } : {}), @@ -302,6 +357,39 @@ export class OpenCodeClient { continue; } + if (sseEvent.type === 'question.asked') { + const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties; + if (questionProps.sessionID === sessionId) { + if (!options.onAskUserQuestion) { + await client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }); + success = false; + failureMessage = 'OpenCode asked a question, but no question handler is configured'; + break; + } + + try { + const answers = await options.onAskUserQuestion(toQuestionInput(questionProps)); + await client.question.reply({ + requestID: questionProps.id, + directory: options.cwd, + answers: toQuestionAnswers(questionProps, answers), + }); + } catch { + await client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }); + success = false; + failureMessage = 'OpenCode question handling failed'; + break; + } + } + continue; + } + if (sseEvent.type === 'message.updated') { const messageProps = sseEvent.properties as { info?: { diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts index ae3976f..d25816d 100644 --- a/src/infra/opencode/types.ts +++ b/src/infra/opencode/types.ts @@ -3,6 +3,7 @@ */ import type { StreamCallback } from '../claude/index.js'; +import type { AskUserQuestionHandler } from '../../core/piece/types.js'; import type { PermissionMode } from '../../core/models/index.js'; /** OpenCode permission reply values */ @@ -29,6 +30,7 @@ export interface OpenCodeCallOptions { permissionMode?: PermissionMode; /** Enable streaming mode with callback (best-effort) */ onStream?: StreamCallback; + onAskUserQuestion?: AskUserQuestionHandler; /** OpenCode API key */ opencodeApiKey?: string; } diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts index aa02680..d5df0aa 100644 --- a/src/infra/providers/opencode.ts +++ b/src/infra/providers/opencode.ts @@ -19,6 +19,7 @@ function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions { model: options.model, permissionMode: options.permissionMode, onStream: options.onStream, + onAskUserQuestion: options.onAskUserQuestion, opencodeApiKey: options.opencodeApiKey ?? resolveOpencodeApiKey(), }; }