opencode の question を抑制
This commit is contained in:
parent
77cd485c22
commit
fc1dfcc3c0
@ -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']));
|
||||
|
||||
@ -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']],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user