feat(opencode): add plan mode, reasoning effort, and status telemetry (#688)

* feat(opencode): support plan mode

* feat(opencode): support reasoning effort

* feat(opencode): surface context usage in web

Bridge OpenCode ACP usage updates into the existing token-count pipeline so the web status bar can show live context and cache information without a separate UI path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(opencode): restrict plan mode to remote, rollback reasoning effort on failure

- Block local OpenCode plan startup (tools not enforced in local path)
- Allow remote OpenCode plan only (ACP permission handler denies tools)
- Guard web /permission-mode endpoint for local OpenCode plan sessions
- Rollback session reasoning effort when OpenCode rejects set_config_option
- Wire rollback callback through opencodeLoop to runOpencode closure
- Add tests: local plan rejected, remote plan allowed, web guard, effort rollback

* fix(web): auto-retry OpenCode models query to populate model selector without refresh

- Retry early failures (RPC may still be registering on new sessions)
- Poll briefly until availableModels is non-empty
- Stop polling once model options are discovered
- Add tests for retry/poll/stop policy

* fix(opencode): cap model discovery polling

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-05-27 11:17:24 +08:00
committed by GitHub
co-authored by Cursor
parent d5a67b717c
commit 6f2bb7d32b
43 changed files with 1263 additions and 68 deletions
+7 -2
View File
@@ -18,10 +18,12 @@ interface OpencodeLoopOptions {
api: ApiClient;
permissionMode?: PermissionMode;
model?: string;
modelReasoningEffort?: string | null;
resumeSessionId?: string;
hookServer: OpencodeHookServer;
hookUrl: string;
onSessionReady?: (session: OpencodeSession) => void;
onReasoningEffortRollback?: (effort: string | null) => void;
}
export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
@@ -40,7 +42,8 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
mode: startingMode,
startedBy,
startingMode,
permissionMode: opts.permissionMode ?? 'default'
permissionMode: opts.permissionMode ?? 'default',
modelReasoningEffort: opts.modelReasoningEffort
});
if (opts.resumeSessionId) {
@@ -55,7 +58,9 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
hookServer: opts.hookServer,
hookUrl: opts.hookUrl
}),
runRemote: (instance) => opencodeRemoteLauncher(instance),
runRemote: (instance) => opencodeRemoteLauncher(instance, {
onReasoningEffortRollback: opts.onReasoningEffortRollback
}),
onSessionReady: opts.onSessionReady
});
}
+117 -8
View File
@@ -4,9 +4,13 @@ import type { OpencodeMode, PermissionMode } from './types';
const harness = vi.hoisted(() => ({
setModelArgs: [] as Array<{ sessionId: string; modelId: string; flavor?: string }>,
setConfigOptionArgs: [] as Array<{ sessionId: string; configId: string; value: string }>,
promptCount: 0,
promptContents: [] as unknown[],
events: [] as string[],
setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise<void>)
setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise<void>),
setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise<void>),
thoughtLevelOption: null as null | { id: string; currentValue?: string; options: Array<{ value: string; name?: string }> }
}));
vi.mock('./utils/opencodeBackend', () => ({
@@ -21,7 +25,18 @@ vi.mock('./utils/opencodeBackend', () => ({
await harness.setModelImpl(sessionId, modelId);
}
}),
prompt: vi.fn(async () => {
setConfigOption: vi.fn(async (sessionId: string, configId: string, value: string) => {
harness.events.push(`setConfigOption:${value}`);
harness.setConfigOptionArgs.push({ sessionId, configId, value });
if (harness.setConfigOptionImpl) {
await harness.setConfigOptionImpl(sessionId, configId, value);
}
if (harness.thoughtLevelOption) {
harness.thoughtLevelOption = { ...harness.thoughtLevelOption, currentValue: value };
}
}),
prompt: vi.fn(async (_sessionId: string, content: unknown[]) => {
harness.promptContents.push(content);
harness.events.push('prompt:start');
harness.promptCount++;
await new Promise<void>((resolve) => setImmediate(resolve));
@@ -32,7 +47,8 @@ vi.mock('./utils/opencodeBackend', () => ({
onStderrError: vi.fn(),
onPermissionRequest: vi.fn(),
disconnect: vi.fn(async () => {}),
getSessionModelsMetadata: vi.fn(() => undefined)
getSessionModelsMetadata: vi.fn(() => undefined),
getThoughtLevelConfigOption: vi.fn(() => harness.thoughtLevelOption ?? undefined)
}))
}));
@@ -70,6 +86,21 @@ function createMode(model?: string): OpencodeMode {
};
}
function createPlanMode(model?: string): OpencodeMode {
return {
permissionMode: 'plan' as PermissionMode,
model
};
}
function createModeWithEffort(model: string | undefined, modelReasoningEffort: string | null): OpencodeMode {
return {
permissionMode: 'default' as PermissionMode,
model,
modelReasoningEffort
};
}
function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>) {
const queue = new MessageQueue2<OpencodeMode>((mode) => JSON.stringify(mode));
items.forEach(({ message, mode }, index) => {
@@ -83,6 +114,8 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>
const sessionEvents: Array<{ type: string; [key: string]: unknown }> = [];
const rpcHandlers = new Map<string, (params: unknown) => unknown>();
const setModelReasoningEffort = vi.fn();
const pushKeepAlive = vi.fn();
const client = {
rpcHandlerManager: {
@@ -108,6 +141,8 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>
return 'default' as const;
},
setModel(_model: string | null) {},
setModelReasoningEffort,
pushKeepAlive,
onThinkingChange(thinking: boolean) {
session.thinking = thinking;
},
@@ -121,15 +156,19 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>
sendUserMessage(_text: string) {}
};
return { session, sessionEvents, rpcHandlers };
return { session, sessionEvents, rpcHandlers, setModelReasoningEffort, pushKeepAlive };
}
describe('opencodeRemoteLauncher inline model switch', () => {
afterEach(() => {
harness.setModelArgs = [];
harness.setConfigOptionArgs = [];
harness.promptCount = 0;
harness.promptContents = [];
harness.events = [];
harness.setModelImpl = null;
harness.setConfigOptionImpl = null;
harness.thoughtLevelOption = null;
});
it('calls setModel with opencode flavor between turns when the queued model differs', async () => {
@@ -209,6 +248,77 @@ describe('opencodeRemoteLauncher inline model switch', () => {
expect(harness.promptCount).toBe(2);
});
it('calls setConfigOption for OpenCode reasoning effort changes', async () => {
harness.thoughtLevelOption = {
id: 'effort',
currentValue: 'low',
options: [
{ value: 'low', name: 'Low' },
{ value: 'high', name: 'High' }
]
};
const { session } = createSessionStub([
{ message: 'first', mode: createModeWithEffort(undefined, 'high') }
]);
await opencodeRemoteLauncher(session as never);
expect(harness.setConfigOptionArgs).toEqual([
{ sessionId: 'acp-session-1', configId: 'effort', value: 'high' }
]);
expect(harness.promptCount).toBe(1);
});
it('rolls back session reasoning effort when OpenCode rejects the switch', async () => {
harness.thoughtLevelOption = {
id: 'effort',
currentValue: 'low',
options: [
{ value: 'low', name: 'Low' },
{ value: 'high', name: 'High' }
]
};
harness.setConfigOptionImpl = async () => {
throw new Error('Transient backend failure');
};
const { session, sessionEvents, setModelReasoningEffort, pushKeepAlive } = createSessionStub([
{ message: 'first', mode: createModeWithEffort(undefined, 'high') }
]);
const rollbacks: Array<string | null> = [];
await opencodeRemoteLauncher(session as never, {
onReasoningEffortRollback: (effort) => rollbacks.push(effort)
});
expect(harness.setConfigOptionArgs).toEqual([
{ sessionId: 'acp-session-1', configId: 'effort', value: 'high' }
]);
expect(setModelReasoningEffort).toHaveBeenCalledWith('low');
expect(pushKeepAlive).toHaveBeenCalledTimes(1);
expect(rollbacks).toEqual(['low']);
expect(sessionEvents.some(
(event) => event.type === 'message'
&& typeof event.message === 'string'
&& event.message.includes('Failed to switch reasoning effort')
)).toBe(true);
expect(harness.promptCount).toBe(1);
});
it('injects plan-mode instructions into plan turns', async () => {
const { session } = createSessionStub([
{ message: 'design the fix', mode: createPlanMode() }
]);
await opencodeRemoteLauncher(session as never);
const content = harness.promptContents[0] as Array<{ type: string; text: string }>;
expect(content[0]?.text).toContain('You are in plan mode');
expect(content[0]?.text).toContain('Do not execute tools');
expect(content[0]?.text).toContain('design the fix');
});
it('registers a listOpencodeModels RPC handler that returns the backend cache', async () => {
// Override getSessionModelsMetadata for this run only.
const fixtureModels = [
@@ -251,7 +361,7 @@ describe('opencodeRemoteLauncher inline model switch', () => {
});
});
it('listOpencodeModels handler returns empty cache when backend has no metadata', async () => {
it('listOpencodeModels handler returns unavailable when backend has no metadata', async () => {
const { session, rpcHandlers } = createSessionStub([
{ message: 'first', mode: createMode() }
]);
@@ -261,9 +371,8 @@ describe('opencodeRemoteLauncher inline model switch', () => {
expect(handler).toBeDefined();
const result = await handler!(undefined) as Record<string, unknown>;
expect(result).toEqual({
success: true,
availableModels: [],
currentModelId: null
success: false,
error: 'OpenCode model metadata is not available'
});
});
+66 -7
View File
@@ -6,11 +6,15 @@ import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'
import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase';
import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay';
import type { OpencodeSession } from './session';
import type { PermissionMode } from './types';
import type { OpencodeMode, PermissionMode } from './types';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
import { createOpencodeBackend } from './utils/opencodeBackend';
import { OpencodePermissionHandler } from './utils/permissionHandler';
import { TITLE_INSTRUCTION } from './utils/systemPrompt';
import { PLAN_MODE_INSTRUCTION, TITLE_INSTRUCTION } from './utils/systemPrompt';
type OpencodeRemoteLauncherOptions = {
onReasoningEffortRollback?: (effort: string | null) => void;
};
class OpencodeRemoteLauncher extends RemoteLauncherBase {
private readonly session: OpencodeSession;
@@ -21,9 +25,15 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
private displayPermissionMode: PermissionMode | null = null;
private instructionsSent = false;
private currentBackendModel: string | null = null;
private currentBackendEffort: string | null = null;
private defaultBackendEffort: string | null = null;
private setModelSupported: boolean | undefined = undefined;
private setEffortSupported: boolean | undefined = undefined;
constructor(session: OpencodeSession) {
constructor(
session: OpencodeSession,
private readonly options: OpencodeRemoteLauncherOptions = {}
) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
}
@@ -93,13 +103,16 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
// does not trigger a redundant setModel on the very first turn.
const initialMetadata = backend.getSessionModelsMetadata?.(acpSessionId);
this.currentBackendModel = initialMetadata?.currentModelId ?? null;
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
// Expose the cached models metadata via per-session RPC so the hub can
// forward it to the web UI's model selector without round-tripping ACP.
session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeModels, async () => {
const metadata = backend.getSessionModelsMetadata?.(acpSessionId);
if (!metadata) {
return { success: true, availableModels: [], currentModelId: null };
return { success: false, error: 'OpenCode model metadata is not available' };
}
return {
success: true,
@@ -175,13 +188,49 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
}
}
const requestedEffort = batch.mode.modelReasoningEffort ?? this.defaultBackendEffort;
if (requestedEffort && requestedEffort !== this.currentBackendEffort) {
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
if (!backend.setConfigOption || !thoughtLevelOption || this.setEffortSupported === false) {
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
} else {
logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${requestedEffort}`);
try {
await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, requestedEffort);
this.currentBackendEffort = requestedEffort;
this.setEffortSupported = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const methodNotFound = /method not found/i.test(message);
if (methodNotFound && this.setEffortSupported === undefined) {
this.setEffortSupported = false;
logger.warn('[opencode-remote] OpenCode build does not support session/set_config_option; inline effort switching disabled for this session');
session.sendSessionEvent({
type: 'message',
message: 'This OpenCode build does not support inline reasoning effort switching.'
});
} else {
logger.warn('[opencode-remote] Inline effort switch failed', error);
session.sendSessionEvent({
type: 'message',
message: `Failed to switch reasoning effort to ${requestedEffort}. Continuing with ${this.currentBackendEffort ?? '(default)'}.`
});
}
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
}
}
}
this.applyDisplayMode(batch.mode.permissionMode);
messageBuffer.addMessage(batch.message, 'user');
// Inject title instructions on first prompt
let messageText = batch.message;
if (batch.mode.permissionMode === 'plan') {
messageText = `${PLAN_MODE_INSTRUCTION}\n\n${messageText}`;
}
if (!this.instructionsSent) {
messageText = `${TITLE_INSTRUCTION}\n\n${batch.message}`;
messageText = `${TITLE_INSTRUCTION}\n\n${messageText}`;
this.instructionsSent = true;
}
@@ -232,6 +281,13 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
}
}
private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void {
batch.mode.modelReasoningEffort = effort;
this.session.setModelReasoningEffort(effort);
this.session.pushKeepAlive();
this.options.onReasoningEffortRollback?.(effort);
}
private handleAgentMessage(message: AgentMessage): void {
const converted = convertAgentMessage(message);
if (converted) {
@@ -254,6 +310,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
case 'tool_result':
this.messageBuffer.addMessage('Tool result received', 'result');
break;
case 'usage':
break;
case 'plan':
this.messageBuffer.addMessage('Plan updated', 'status');
break;
@@ -313,8 +371,9 @@ function toAcpMcpServers(config: Record<string, { command: string; args: string[
}
export async function opencodeRemoteLauncher(
session: OpencodeSession
session: OpencodeSession,
options: OpencodeRemoteLauncherOptions = {}
): Promise<'switch' | 'exit'> {
const launcher = new OpencodeRemoteLauncher(session);
const launcher = new OpencodeRemoteLauncher(session, options);
return launcher.launch();
}
+40
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockOpencodeSession = vi.hoisted(() => ({
setModel: vi.fn(),
setPermissionMode: vi.fn(),
setModelReasoningEffort: vi.fn(),
pushKeepAlive: vi.fn(),
thinking: false,
stopKeepAlive: vi.fn()
@@ -89,6 +90,7 @@ describe('runOpencode set-session-config handler', () => {
harness.opencodeLoopError = null;
mockOpencodeSession.setModel.mockReset();
mockOpencodeSession.setPermissionMode.mockReset();
mockOpencodeSession.setModelReasoningEffort.mockReset();
mockOpencodeSession.pushKeepAlive.mockReset();
harness.session.onUserMessage.mockReset();
harness.session.rpcHandlerManager.registerHandler.mockReset();
@@ -109,6 +111,20 @@ describe('runOpencode set-session-config handler', () => {
return configHandler![1] as (payload: unknown) => Promise<unknown>;
}
it('rejects plan mode for local OpenCode startup', async () => {
await expect(runOpencode({ permissionMode: 'plan' })).rejects.toThrow(
'OpenCode plan mode is only supported in remote mode'
);
expect(harness.opencodeLoopArgs).toEqual([]);
});
it('allows plan mode for remote OpenCode startup', async () => {
await runOpencode({ permissionMode: 'plan', startingMode: 'remote' });
expect(harness.opencodeLoopArgs[0]?.permissionMode).toBe('plan');
expect(harness.opencodeLoopArgs[0]?.startingMode).toBe('remote');
});
it('applies model change via set-session-config RPC', async () => {
await runOpencode({});
@@ -178,6 +194,30 @@ describe('runOpencode set-session-config handler', () => {
expect(applied.permissionMode).toBe('yolo');
});
it('accepts plan mode via set-session-config RPC', async () => {
await runOpencode({});
const handler = getConfigHandler();
const result = await handler({ permissionMode: 'plan' }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
expect(applied.permissionMode).toBe('plan');
expect(mockOpencodeSession.setPermissionMode).toHaveBeenLastCalledWith('plan');
});
it('accepts model reasoning effort via set-session-config RPC', async () => {
await runOpencode({});
const handler = getConfigHandler();
const result = await handler({ modelReasoningEffort: 'high' }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
expect(applied.modelReasoningEffort).toBe('high');
expect(mockOpencodeSession.setModelReasoningEffort).toHaveBeenLastCalledWith('high');
});
it('passes initial model from opts through to the loop', async () => {
await runOpencode({ model: 'ollama/exaone:4.5-33b-q8' });
+26 -7
View File
@@ -19,6 +19,7 @@ export async function runOpencode(opts: {
startingMode?: 'local' | 'remote';
permissionMode?: PermissionMode;
model?: string;
modelReasoningEffort?: string | null;
resumeSessionId?: string;
existingSessionId?: string;
workingDirectory?: string;
@@ -33,6 +34,13 @@ export async function runOpencode(opts: {
opts.startingMode = 'remote';
}
const startingMode: 'local' | 'remote' = opts.startingMode
?? (startedBy === 'runner' ? 'remote' : 'local');
if (opts.permissionMode === 'plan' && startingMode !== 'remote') {
throw new Error('OpenCode plan mode is only supported in remote mode');
}
const initialState: AgentState = {
controlledByUser: false
};
@@ -41,6 +49,7 @@ export async function runOpencode(opts: {
// Mid-session selections are persisted by the hub via the set-session-config RPC,
// not by this initial bootstrap.
const initialModel = opts.model ?? null;
const initialModelReasoningEffort = opts.modelReasoningEffort ?? null;
const bootstrap = opts.existingSessionId
? await bootstrapExistingSession({
@@ -54,23 +63,23 @@ export async function runOpencode(opts: {
startedBy,
workingDirectory,
agentState: initialState,
model: initialModel ?? undefined
model: initialModel ?? undefined,
modelReasoningEffort: initialModelReasoningEffort ?? undefined
});
const { api, session } = bootstrap;
const startingMode: 'local' | 'remote' = opts.startingMode
?? (startedBy === 'runner' ? 'remote' : 'local');
setControlledByUser(session, startingMode);
const messageQueue = new MessageQueue2<OpencodeMode>((mode) => hashObject({
permissionMode: mode.permissionMode,
model: mode.model ?? null
model: mode.model ?? null,
modelReasoningEffort: mode.modelReasoningEffort ?? null
}));
const sessionWrapperRef: { current: OpencodeSession | null } = { current: null };
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
let sessionModel: string | null = initialModel;
let sessionModelReasoningEffort: string | null = initialModelReasoningEffort;
const hookServer = await startOpencodeHookServer({
onEvent: (event) => {
const currentSession = sessionWrapperRef.current;
@@ -102,19 +111,21 @@ export async function runOpencode(opts: {
}
sessionInstance.setPermissionMode(currentPermissionMode);
sessionInstance.setModel(sessionModel);
sessionInstance.setModelReasoningEffort(sessionModelReasoningEffort);
// Notify hub immediately so the UI reflects the change without
// waiting for the next 2s keepalive tick.
sessionInstance.pushKeepAlive();
logger.debug(`[opencode] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${sessionModel ?? '(default)'}`);
logger.debug(`[opencode] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${sessionModel ?? '(default)'}, modelReasoningEffort=${sessionModelReasoningEffort ?? '(default)'}`);
};
session.onUserMessage((message, localId) => {
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
const mode: OpencodeMode = {
permissionMode: currentPermissionMode,
model: sessionModel ?? undefined
model: sessionModel ?? undefined,
modelReasoningEffort: sessionModelReasoningEffort
};
messageQueue.push(formattedText, mode, localId);
});
@@ -129,6 +140,7 @@ export async function runOpencode(opts: {
rpcHandlerManager: session.rpcHandlerManager,
flavor: 'opencode',
modelMode: 'nullable',
modelReasoningEffortMode: 'nullable',
onApply: (config) => {
if (config.permissionMode !== undefined) {
currentPermissionMode = config.permissionMode;
@@ -136,6 +148,9 @@ export async function runOpencode(opts: {
if (config.model !== undefined) {
sessionModel = config.model;
}
if (config.modelReasoningEffort !== undefined) {
sessionModelReasoningEffort = config.modelReasoningEffort;
}
},
onAfterApply: syncSessionMode
});
@@ -152,10 +167,14 @@ export async function runOpencode(opts: {
api,
permissionMode: currentPermissionMode,
model: sessionModel ?? undefined,
modelReasoningEffort: sessionModelReasoningEffort,
resumeSessionId: opts.resumeSessionId,
hookServer,
hookUrl,
onModeChange: createModeChangeHandler(session),
onReasoningEffortRollback: (effort) => {
sessionModelReasoningEffort = effort;
},
onSessionReady: (instance) => {
sessionWrapperRef.current = instance;
syncSessionMode();
+8 -1
View File
@@ -28,6 +28,7 @@ export class OpencodeSession extends AgentSessionBase<OpencodeMode> {
startedBy: 'runner' | 'terminal';
startingMode: 'local' | 'remote';
permissionMode?: PermissionMode;
modelReasoningEffort?: string | null;
}) {
super({
api: opts.api,
@@ -44,12 +45,14 @@ export class OpencodeSession extends AgentSessionBase<OpencodeMode> {
...metadata,
opencodeSessionId: sessionId
}),
permissionMode: opts.permissionMode
permissionMode: opts.permissionMode,
modelReasoningEffort: opts.modelReasoningEffort
});
this.startedBy = opts.startedBy;
this.startingMode = opts.startingMode;
this.permissionMode = opts.permissionMode;
this.modelReasoningEffort = opts.modelReasoningEffort;
}
addHookEventHandler(cb: (event: OpencodeHookEvent) => void): void {
@@ -77,6 +80,10 @@ export class OpencodeSession extends AgentSessionBase<OpencodeMode> {
this.model = model;
};
setModelReasoningEffort = (modelReasoningEffort: string | null): void => {
this.modelReasoningEffort = modelReasoningEffort;
};
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
this.localLaunchFailure = { message, exitReason };
};
+1
View File
@@ -5,6 +5,7 @@ export type PermissionMode = OpencodePermissionMode;
export interface OpencodeMode {
permissionMode: PermissionMode;
model?: string;
modelReasoningEffort?: string | null;
}
export type OpencodeHookEvent = {
@@ -0,0 +1,182 @@
import { describe, expect, it, vi } from 'vitest';
import type { ApiSessionClient } from '@/api/apiSession';
import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types';
import { OpencodePermissionHandler } from './permissionHandler';
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}));
type FakeAgentState = {
requests: Record<string, unknown>;
completedRequests: Record<string, unknown>;
};
function createHarness(getPermissionMode: () => 'default' | 'plan' | 'yolo' = () => 'default') {
let agentState: FakeAgentState = {
requests: {},
completedRequests: {}
};
const rpcHandlers = new Map<string, (params: unknown) => Promise<unknown> | unknown>();
let permissionHandler: ((request: PermissionRequest) => void) | null = null;
const respondCalls: Array<{
sessionId: string;
request: PermissionRequest;
response: PermissionResponse;
}> = [];
const session = {
rpcHandlerManager: {
registerHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown) {
rpcHandlers.set(method, handler);
}
},
updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) {
agentState = handler(agentState);
}
} as unknown as ApiSessionClient;
const backend: AgentBackend = {
async initialize() {},
async newSession() {
return 'agent-session';
},
async prompt() {},
async cancelPrompt() {},
async respondToPermission(sessionId, request, response) {
respondCalls.push({ sessionId, request, response });
},
onPermissionRequest(handler) {
permissionHandler = handler;
},
async disconnect() {}
};
new OpencodePermissionHandler(session, backend, getPermissionMode);
return {
rpcHandlers,
respondCalls,
getAgentState: () => agentState,
emitPermissionRequest(request: PermissionRequest) {
if (!permissionHandler) {
throw new Error('Permission handler was not registered');
}
permissionHandler(request);
}
};
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function buildRequest(overrides?: Partial<PermissionRequest>): PermissionRequest {
return {
id: 'perm-1',
sessionId: 'session-1',
toolCallId: 'perm-1',
title: 'Write',
rawInput: { path: 'file.ts' },
options: [
{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
},
{
optionId: 'reject-once',
name: 'Reject once',
kind: 'reject_once'
}
],
...overrides
};
}
describe('OpencodePermissionHandler plan mode', () => {
it('denies non-auto-approved tool requests instead of queueing them', async () => {
const harness = createHarness(() => 'plan');
harness.emitPermissionRequest(buildRequest());
await flushAsyncWork();
expect(harness.respondCalls).toEqual([
{
sessionId: 'session-1',
request: expect.objectContaining({ id: 'perm-1', title: 'Write' }),
response: { outcome: 'selected', optionId: 'reject-once' }
}
]);
expect(harness.getAgentState().requests).toEqual({});
expect(harness.getAgentState().completedRequests).toMatchObject({
'perm-1': {
tool: 'Write',
status: 'denied',
decision: 'denied',
reason: 'Plan mode blocks tool execution'
}
});
});
it('cancels plan-mode requests when OpenCode offers no reject option', async () => {
const harness = createHarness(() => 'plan');
harness.emitPermissionRequest(buildRequest({
id: 'perm-no-reject',
options: [
{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
}
]
}));
await flushAsyncWork();
expect(harness.respondCalls).toEqual([
{
sessionId: 'session-1',
request: expect.objectContaining({ id: 'perm-no-reject' }),
response: { outcome: 'cancelled' }
}
]);
expect(harness.getAgentState().completedRequests).toMatchObject({
'perm-no-reject': {
status: 'canceled',
decision: 'abort'
}
});
});
it('still auto-approves hapi title updates in plan mode', async () => {
const harness = createHarness(() => 'plan');
harness.emitPermissionRequest(buildRequest({
id: 'perm-title',
toolCallId: 'perm-title',
title: 'hapi_change_title',
rawInput: { title: 'Planning' }
}));
await flushAsyncWork();
expect(harness.respondCalls).toEqual([
{
sessionId: 'session-1',
request: expect.objectContaining({ id: 'perm-title', title: 'hapi_change_title' }),
response: { outcome: 'selected', optionId: 'allow-once' }
}
]);
expect(harness.getAgentState().requests).toEqual({});
expect(harness.getAgentState().completedRequests).toMatchObject({
'perm-title': {
tool: 'hapi_change_title',
status: 'approved',
decision: 'approved'
}
});
});
});
+47 -1
View File
@@ -24,16 +24,28 @@ function deriveToolInput(request: PermissionRequest): unknown {
return request.rawOutput;
}
function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null {
function pickOptionId(
request: PermissionRequest,
preferredKinds: string[],
options: { fallbackToFirst?: boolean } = {}
): string | null {
for (const kind of preferredKinds) {
const match = request.options.find((option) => option.kind === kind);
if (match) {
return match.optionId;
}
}
if (options.fallbackToFirst === false) {
return null;
}
return request.options.length > 0 ? request.options[0].optionId : null;
}
function mapPlanModeDenialToOutcome(request: PermissionRequest): PermissionResponse {
const optionId = pickOptionId(request, ['reject_once', 'reject_always'], { fallbackToFirst: false });
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse {
if (decision === 'abort') {
return { outcome: 'cancelled' };
@@ -80,6 +92,11 @@ export class OpencodePermissionHandler extends BasePermissionHandler<PermissionR
return;
}
if (mode === 'plan') {
void this.denyForPlanMode(request, toolName, toolInput);
return;
}
this.pendingBackendRequests.set(request.id, request);
this.addPendingRequest(request.id, toolName, toolInput, {
resolve: () => {},
@@ -116,6 +133,35 @@ export class OpencodePermissionHandler extends BasePermissionHandler<PermissionR
logger.debug(`[Opencode] Auto-approved ${toolName} (${request.id}) mode=${decision}`);
}
private async denyForPlanMode(
request: PermissionRequest,
toolName: string,
toolInput: unknown
): Promise<void> {
const outcome = mapPlanModeDenialToOutcome(request);
await this.backend.respondToPermission(request.sessionId, request, outcome);
const timestamp = Date.now();
const status = outcome.outcome === 'selected' ? 'denied' : 'canceled';
this.client.updateAgentState((currentState) => ({
...currentState,
completedRequests: {
...currentState.completedRequests,
[request.id]: {
tool: toolName,
arguments: toolInput,
createdAt: timestamp,
completedAt: timestamp,
status,
reason: 'Plan mode blocks tool execution',
decision: status === 'denied' ? 'denied' : 'abort'
}
}
}));
logger.debug(`[Opencode] Denied ${toolName} (${request.id}) in plan mode`);
}
protected async handlePermissionResponse(
response: PermissionResponseMessage,
pending: PendingPermissionRequest<void>
+7
View File
@@ -19,3 +19,10 @@ export const TITLE_INSTRUCTION = trimIdent(`
* The system prompt to inject for OpenCode sessions.
*/
export const opencodeSystemPrompt = TITLE_INSTRUCTION;
/**
* Instruction prepended to OpenCode prompts while HAPI plan mode is active.
*/
export const PLAN_MODE_INSTRUCTION = trimIdent(`
You are in plan mode. Do not execute tools or make changes. Analyze the request, ask clarifying questions if needed, and respond with a concise implementation plan only.
`);