mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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:
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user