opencode の question を抑制

This commit is contained in:
nrslib 2026-02-11 10:08:23 +09:00
parent 77cd485c22
commit fc1dfcc3c0
7 changed files with 268 additions and 0 deletions

View File

@ -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<typeof getProvider>);
const promise = interactiveMode('/project', 'trigger');
await new Promise<void>((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']));

View File

@ -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']],
});
});
});

View File

@ -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);
}
}

View File

@ -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<string, unknown> };
/** Tracking state for stream offsets during a single OpenCode session */

View File

@ -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<string, string>,
): Array<Array<string>> {
return props.questions.map((item) => {
const key = item.header || item.question;
const value = answers[key];
if (!value) return [];
return [value];
});
}
async function getFreePort(): Promise<number> {
return new Promise<number>((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?: {

View File

@ -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;
}

View File

@ -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(),
};
}