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 () => {
|
it('should use saved sessionId from initializeSession when no sessionId parameter is given', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupRawStdin(toRawInputs(['hello', '/cancel']));
|
setupRawStdin(toRawInputs(['hello', '/cancel']));
|
||||||
|
|||||||
@ -245,4 +245,115 @@ describe('OpenCodeClient stream cleanup', () => {
|
|||||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
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 { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
|
||||||
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
||||||
import { readMultilineInput } from './lineEditor.js';
|
import { readMultilineInput } from './lineEditor.js';
|
||||||
|
import { EXIT_SIGINT } from '../../shared/exitCodes.js';
|
||||||
import {
|
import {
|
||||||
type PieceContext,
|
type PieceContext,
|
||||||
type InteractiveModeResult,
|
type InteractiveModeResult,
|
||||||
@ -97,6 +98,21 @@ export async function callAIWithRetry(
|
|||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> {
|
): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> {
|
||||||
const display = new StreamDisplay('assistant', isQuietMode());
|
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;
|
let { sessionId } = ctx;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -106,6 +122,7 @@ export async function callAIWithRetry(
|
|||||||
model: ctx.model,
|
model: ctx.model,
|
||||||
sessionId,
|
sessionId,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
|
abortSignal: abortController.signal,
|
||||||
onStream: display.createHandler(),
|
onStream: display.createHandler(),
|
||||||
});
|
});
|
||||||
display.flush();
|
display.flush();
|
||||||
@ -121,6 +138,7 @@ export async function callAIWithRetry(
|
|||||||
model: ctx.model,
|
model: ctx.model,
|
||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
|
abortSignal: abortController.signal,
|
||||||
onStream: retryDisplay.createHandler(),
|
onStream: retryDisplay.createHandler(),
|
||||||
});
|
});
|
||||||
retryDisplay.flush();
|
retryDisplay.flush();
|
||||||
@ -148,6 +166,8 @@ export async function callAIWithRetry(
|
|||||||
error(msg);
|
error(msg);
|
||||||
blankLine();
|
blankLine();
|
||||||
return { result: null, sessionId };
|
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 =
|
export type OpenCodeStreamEvent =
|
||||||
| OpenCodeMessagePartUpdatedEvent
|
| OpenCodeMessagePartUpdatedEvent
|
||||||
| OpenCodeMessageUpdatedEvent
|
| OpenCodeMessageUpdatedEvent
|
||||||
@ -94,6 +111,7 @@ export type OpenCodeStreamEvent =
|
|||||||
| OpenCodeSessionIdleEvent
|
| OpenCodeSessionIdleEvent
|
||||||
| OpenCodeSessionErrorEvent
|
| OpenCodeSessionErrorEvent
|
||||||
| OpenCodePermissionAskedEvent
|
| OpenCodePermissionAskedEvent
|
||||||
|
| OpenCodeQuestionAskedEvent
|
||||||
| { type: string; properties: Record<string, unknown> };
|
| { type: string; properties: Record<string, unknown> };
|
||||||
|
|
||||||
/** Tracking state for stream offsets during a single OpenCode session */
|
/** Tracking state for stream offsets during a single OpenCode session */
|
||||||
|
|||||||
@ -83,6 +83,60 @@ function stripPromptEcho(
|
|||||||
return chunk;
|
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> {
|
async function getFreePort(): Promise<number> {
|
||||||
return new Promise<number>((resolve, reject) => {
|
return new Promise<number>((resolve, reject) => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
@ -205,6 +259,7 @@ export class OpenCodeClient {
|
|||||||
const config = {
|
const config = {
|
||||||
model: fullModel,
|
model: fullModel,
|
||||||
small_model: fullModel,
|
small_model: fullModel,
|
||||||
|
permission: { question: 'deny' as const },
|
||||||
...(options.opencodeApiKey
|
...(options.opencodeApiKey
|
||||||
? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } }
|
? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } }
|
||||||
: {}),
|
: {}),
|
||||||
@ -302,6 +357,39 @@ export class OpenCodeClient {
|
|||||||
continue;
|
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') {
|
if (sseEvent.type === 'message.updated') {
|
||||||
const messageProps = sseEvent.properties as {
|
const messageProps = sseEvent.properties as {
|
||||||
info?: {
|
info?: {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { StreamCallback } from '../claude/index.js';
|
import type { StreamCallback } from '../claude/index.js';
|
||||||
|
import type { AskUserQuestionHandler } from '../../core/piece/types.js';
|
||||||
import type { PermissionMode } from '../../core/models/index.js';
|
import type { PermissionMode } from '../../core/models/index.js';
|
||||||
|
|
||||||
/** OpenCode permission reply values */
|
/** OpenCode permission reply values */
|
||||||
@ -29,6 +30,7 @@ export interface OpenCodeCallOptions {
|
|||||||
permissionMode?: PermissionMode;
|
permissionMode?: PermissionMode;
|
||||||
/** Enable streaming mode with callback (best-effort) */
|
/** Enable streaming mode with callback (best-effort) */
|
||||||
onStream?: StreamCallback;
|
onStream?: StreamCallback;
|
||||||
|
onAskUserQuestion?: AskUserQuestionHandler;
|
||||||
/** OpenCode API key */
|
/** OpenCode API key */
|
||||||
opencodeApiKey?: string;
|
opencodeApiKey?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions {
|
|||||||
model: options.model,
|
model: options.model,
|
||||||
permissionMode: options.permissionMode,
|
permissionMode: options.permissionMode,
|
||||||
onStream: options.onStream,
|
onStream: options.onStream,
|
||||||
|
onAskUserQuestion: options.onAskUserQuestion,
|
||||||
opencodeApiKey: options.opencodeApiKey ?? resolveOpencodeApiKey(),
|
opencodeApiKey: options.opencodeApiKey ?? resolveOpencodeApiKey(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user