opencode に対して report fase は deny

This commit is contained in:
nrslib 2026-02-11 22:35:19 +09:00
parent ef0eeb057f
commit b4a224c0f0
7 changed files with 177 additions and 5 deletions

View File

@ -399,6 +399,52 @@ describe('OpenCodeClient stream cleanup', () => {
); );
}); });
it('should pass empty tools object to promptAsync when allowedTools is an explicit empty array', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([
{
type: 'message.updated',
properties: {
info: {
sessionID: 'session-empty-tools',
role: 'assistant',
time: { created: Date.now(), completed: Date.now() + 1 },
},
},
},
]);
const promptAsync = vi.fn().mockResolvedValue(undefined);
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-empty-tools' } });
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const subscribe = vi.fn().mockResolvedValue({ stream });
createOpencodeMock.mockResolvedValue({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe },
permission: { reply: vi.fn() },
},
server: { close: vi.fn() },
});
const client = new OpenCodeClient();
const result = await client.call('coder', 'hello', {
cwd: '/tmp',
model: 'opencode/big-pickle',
allowedTools: [],
});
expect(result.status).toBe('done');
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
tools: {},
}),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it('should configure allow permissions for edit mode', async () => { it('should configure allow permissions for edit mode', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js'); const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([ const stream = new MockEventStream([

View File

@ -54,7 +54,10 @@ describe('mapToOpenCodeTools', () => {
it('should return undefined when tools are not provided', () => { it('should return undefined when tools are not provided', () => {
expect(mapToOpenCodeTools(undefined)).toBeUndefined(); expect(mapToOpenCodeTools(undefined)).toBeUndefined();
expect(mapToOpenCodeTools([])).toBeUndefined(); });
it('should return empty tool map when explicit empty tools are provided', () => {
expect(mapToOpenCodeTools([])).toEqual({});
}); });
}); });

View File

@ -6,7 +6,7 @@
import { Codex } from '@openai/codex-sdk'; import { Codex } from '@openai/codex-sdk';
import type { AgentResponse } from '../../core/models/index.js'; import type { AgentResponse } from '../../core/models/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { createLogger, getErrorMessage, createStreamDiagnostics, type StreamDiagnostics } from '../../shared/utils/index.js';
import { mapToCodexSandboxMode, type CodexCallOptions } from './types.js'; import { mapToCodexSandboxMode, type CodexCallOptions } from './types.js';
import { import {
type CodexEvent, type CodexEvent,
@ -113,12 +113,14 @@ export class CodexClient {
const streamAbortController = new AbortController(); const streamAbortController = new AbortController();
const timeoutMessage = `Codex stream timed out after ${Math.floor(CODEX_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; const timeoutMessage = `Codex stream timed out after ${Math.floor(CODEX_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
let abortCause: 'timeout' | 'external' | undefined; let abortCause: 'timeout' | 'external' | undefined;
let diagRef: StreamDiagnostics | undefined;
const resetIdleTimeout = (): void => { const resetIdleTimeout = (): void => {
if (idleTimeoutId !== undefined) { if (idleTimeoutId !== undefined) {
clearTimeout(idleTimeoutId); clearTimeout(idleTimeoutId);
} }
idleTimeoutId = setTimeout(() => { idleTimeoutId = setTimeout(() => {
diagRef?.onIdleTimeoutFired();
abortCause = 'timeout'; abortCause = 'timeout';
streamAbortController.abort(); streamAbortController.abort();
}, CODEX_STREAM_IDLE_TIMEOUT_MS); }, CODEX_STREAM_IDLE_TIMEOUT_MS);
@ -145,10 +147,14 @@ export class CodexClient {
attempt, attempt,
}); });
const diag = createStreamDiagnostics('codex-sdk', { agentType, model: options.model, attempt });
diagRef = diag;
const { events } = await thread.runStreamed(fullPrompt, { const { events } = await thread.runStreamed(fullPrompt, {
signal: streamAbortController.signal, signal: streamAbortController.signal,
}); });
resetIdleTimeout(); resetIdleTimeout();
diag.onConnected();
let content = ''; let content = '';
const contentOffsets = new Map<string, number>(); const contentOffsets = new Map<string, number>();
@ -158,6 +164,8 @@ export class CodexClient {
for await (const event of events as AsyncGenerator<CodexEvent>) { for await (const event of events as AsyncGenerator<CodexEvent>) {
resetIdleTimeout(); resetIdleTimeout();
diag.onFirstEvent(event.type);
diag.onEvent(event.type);
if (event.type === 'thread.started') { if (event.type === 'thread.started') {
currentThreadId = typeof event.thread_id === 'string' ? event.thread_id : currentThreadId; currentThreadId = typeof event.thread_id === 'string' ? event.thread_id : currentThreadId;
@ -170,12 +178,14 @@ export class CodexClient {
if (event.error && typeof event.error === 'object' && 'message' in event.error) { if (event.error && typeof event.error === 'object' && 'message' in event.error) {
failureMessage = String((event.error as { message?: unknown }).message ?? ''); failureMessage = String((event.error as { message?: unknown }).message ?? '');
} }
diag.onStreamError('turn.failed', failureMessage);
break; break;
} }
if (event.type === 'error') { if (event.type === 'error') {
success = false; success = false;
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error';
diag.onStreamError('error', failureMessage);
break; break;
} }
@ -237,6 +247,8 @@ export class CodexClient {
} }
} }
diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage);
if (!success) { if (!success) {
const message = failureMessage || 'Codex execution failed'; const message = failureMessage || 'Codex execution failed';
const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause); const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause);
@ -275,6 +287,11 @@ export class CodexClient {
: CODEX_STREAM_ABORTED_MESSAGE : CODEX_STREAM_ABORTED_MESSAGE
: message; : message;
diagRef?.onCompleted(
abortCause === 'timeout' ? 'timeout' : streamAbortController.signal.aborted ? 'abort' : 'error',
errorMessage,
);
const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause);
if (retriable && attempt < CODEX_RETRY_MAX_ATTEMPTS) { if (retriable && attempt < CODEX_RETRY_MAX_ATTEMPTS) {
log.info('Retrying Codex call after transient exception', { agentType, attempt, errorMessage }); log.info('Retrying Codex call after transient exception', { agentType, attempt, errorMessage });

View File

@ -8,7 +8,7 @@
import { createOpencode } from '@opencode-ai/sdk/v2'; import { createOpencode } from '@opencode-ai/sdk/v2';
import { createServer } from 'node:net'; import { createServer } from 'node:net';
import type { AgentResponse } from '../../core/models/index.js'; import type { AgentResponse } from '../../core/models/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { createLogger, getErrorMessage, createStreamDiagnostics, type StreamDiagnostics } from '../../shared/utils/index.js';
import { parseProviderModel } from '../../shared/utils/providerModel.js'; import { parseProviderModel } from '../../shared/utils/providerModel.js';
import { import {
buildOpenCodePermissionConfig, buildOpenCodePermissionConfig,
@ -251,6 +251,7 @@ export class OpenCodeClient {
const streamAbortController = new AbortController(); const streamAbortController = new AbortController();
const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
let abortCause: 'timeout' | 'external' | undefined; let abortCause: 'timeout' | 'external' | undefined;
let diagRef: StreamDiagnostics | undefined;
let serverClose: (() => void) | undefined; let serverClose: (() => void) | undefined;
let opencodeApiClient: Awaited<ReturnType<typeof createOpencode>>['client'] | undefined; let opencodeApiClient: Awaited<ReturnType<typeof createOpencode>>['client'] | undefined;
@ -259,6 +260,7 @@ export class OpenCodeClient {
clearTimeout(idleTimeoutId); clearTimeout(idleTimeoutId);
} }
idleTimeoutId = setTimeout(() => { idleTimeoutId = setTimeout(() => {
diagRef?.onIdleTimeoutFired();
abortCause = 'timeout'; abortCause = 'timeout';
streamAbortController.abort(); streamAbortController.abort();
}, OPENCODE_STREAM_IDLE_TIMEOUT_MS); }, OPENCODE_STREAM_IDLE_TIMEOUT_MS);
@ -285,6 +287,9 @@ export class OpenCodeClient {
attempt, attempt,
}); });
const diag = createStreamDiagnostics('opencode-sdk', { agentType, model: options.model, attempt });
diagRef = diag;
const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const parsedModel = parseProviderModel(options.model, 'OpenCode model');
const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`;
const port = await getFreePort(); const port = await getFreePort();
@ -321,6 +326,7 @@ export class OpenCodeClient {
{ signal: streamAbortController.signal }, { signal: streamAbortController.signal },
); );
resetIdleTimeout(); resetIdleTimeout();
diag.onConnected();
const tools = mapToOpenCodeTools(options.allowedTools); const tools = mapToOpenCodeTools(options.allowedTools);
await client.session.promptAsync( await client.session.promptAsync(
@ -349,6 +355,8 @@ export class OpenCodeClient {
resetIdleTimeout(); resetIdleTimeout();
const sseEvent = event as OpenCodeStreamEvent; const sseEvent = event as OpenCodeStreamEvent;
diag.onFirstEvent(sseEvent.type);
diag.onEvent(sseEvent.type);
if (sseEvent.type === 'message.part.updated') { if (sseEvent.type === 'message.part.updated') {
const props = sseEvent.properties as { part: OpenCodePart; delta?: string }; const props = sseEvent.properties as { part: OpenCodePart; delta?: string };
const part = props.part; const part = props.part;
@ -458,6 +466,7 @@ export class OpenCodeClient {
if (streamError) { if (streamError) {
success = false; success = false;
failureMessage = streamError; failureMessage = streamError;
diag.onStreamError('message.updated', streamError);
break; break;
} }
} }
@ -479,6 +488,7 @@ export class OpenCodeClient {
if (streamError) { if (streamError) {
success = false; success = false;
failureMessage = streamError; failureMessage = streamError;
diag.onStreamError('message.completed', streamError);
break; break;
} }
} }
@ -498,6 +508,7 @@ export class OpenCodeClient {
if (isCurrentAssistantMessage) { if (isCurrentAssistantMessage) {
success = false; success = false;
failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed'; failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed';
diag.onStreamError('message.failed', failureMessage);
break; break;
} }
continue; continue;
@ -530,6 +541,7 @@ export class OpenCodeClient {
if (!errorProps.sessionID || errorProps.sessionID === sessionId) { if (!errorProps.sessionID || errorProps.sessionID === sessionId) {
success = false; success = false;
failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error'; failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error';
diag.onStreamError('session.error', failureMessage);
break; break;
} }
continue; continue;
@ -537,6 +549,7 @@ export class OpenCodeClient {
} }
content = [...textContentParts.values()].join('\n'); content = [...textContentParts.values()].join('\n');
diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage);
if (!success) { if (!success) {
const message = failureMessage || 'OpenCode execution failed'; const message = failureMessage || 'OpenCode execution failed';
@ -575,6 +588,11 @@ export class OpenCodeClient {
: OPENCODE_STREAM_ABORTED_MESSAGE : OPENCODE_STREAM_ABORTED_MESSAGE
: message; : message;
diagRef?.onCompleted(
abortCause === 'timeout' ? 'timeout' : streamAbortController.signal.aborted ? 'abort' : 'error',
errorMessage,
);
const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause);
if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) {
log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage }); log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage });

View File

@ -127,9 +127,12 @@ const BUILTIN_TOOL_MAP: Record<string, string> = {
}; };
export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, boolean> | undefined { export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, boolean> | undefined {
if (!allowedTools || allowedTools.length === 0) { if (!allowedTools) {
return undefined; return undefined;
} }
if (allowedTools.length === 0) {
return {};
}
const mapped = new Set<string>(); const mapped = new Set<string>();
for (const tool of allowedTools) { for (const tool of allowedTools) {
@ -142,7 +145,7 @@ export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, bool
} }
if (mapped.size === 0) { if (mapped.size === 0) {
return undefined; return {};
} }
const tools: Record<string, boolean> = {}; const tools: Record<string, boolean> = {};

View File

@ -10,6 +10,7 @@ export * from './reportDir.js';
export * from './slackWebhook.js'; export * from './slackWebhook.js';
export * from './sleep.js'; export * from './sleep.js';
export * from './slug.js'; export * from './slug.js';
export * from './streamDiagnostics.js';
export * from './taskPaths.js'; export * from './taskPaths.js';
export * from './text.js'; export * from './text.js';
export * from './types.js'; export * from './types.js';

View File

@ -0,0 +1,84 @@
/**
* Stream lifecycle diagnostics for provider clients.
*
* Tracks connection, iteration, event counts, and completion
* to fill the observability gap between stream start and timeout/error.
* All output is debug-level only.
*/
import { createLogger } from './debug.js';
export interface StreamDiagnostics {
/** Call when the stream connection resolves (runStreamed / subscribe) */
onConnected(): void;
/** Call at the top of each for-await iteration (logs only on first call) */
onFirstEvent(eventType: string): void;
/** Call for each event to track count and last type (no log output) */
onEvent(eventType: string): void;
/** Call when the idle timeout callback fires (before abort) */
onIdleTimeoutFired(): void;
/** Call when error events are received (turn.failed, session.error, etc.) */
onStreamError(eventType: string, message: string): void;
/** Call on stream completion with reason */
onCompleted(reason: 'normal' | 'timeout' | 'abort' | 'error', detail?: string): void;
}
export function createStreamDiagnostics(
component: string,
context: Record<string, unknown>,
): StreamDiagnostics {
const log = createLogger(component);
const startTime = Date.now();
let eventCount = 0;
let lastEventType = '';
let lastEventTime = 0;
let connected = false;
let firstEventLogged = false;
return {
onConnected() {
connected = true;
log.debug('Stream connected', { ...context, elapsedMs: Date.now() - startTime });
},
onFirstEvent(eventType: string) {
if (firstEventLogged) return;
firstEventLogged = true;
log.debug('Stream first event', { ...context, firstEventType: eventType, elapsedMs: Date.now() - startTime });
},
onEvent(eventType: string) {
eventCount++;
lastEventType = eventType;
lastEventTime = Date.now();
},
onIdleTimeoutFired() {
log.debug('Idle timeout fired', {
...context,
eventCount,
lastEventType,
msSinceLastEvent: lastEventTime > 0 ? Date.now() - lastEventTime : undefined,
connected,
iterationStarted: firstEventLogged,
});
},
onStreamError(eventType: string, message: string) {
log.debug('Stream error event', { ...context, eventType, message, eventCount });
},
onCompleted(reason: 'normal' | 'timeout' | 'abort' | 'error', detail?: string) {
log.debug('Stream completed', {
...context,
reason,
detail,
eventCount,
lastEventType,
durationMs: Date.now() - startTime,
connected,
iterationStarted: firstEventLogged,
});
},
};
}