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
@@ -155,6 +155,45 @@ describe('AcpSdkBackend', () => {
});
});
it('captures model metadata from configOptions when models block is missing', async () => {
const backend = new AcpSdkBackend({ command: 'opencode' });
const backendInternal = backend as unknown as {
transport: { sendRequest: (method: string, params: unknown) => Promise<unknown>; close: () => Promise<void> } | null;
};
backendInternal.transport = {
sendRequest: async (method) => {
if (method === 'session/new') {
return {
sessionId: 'opencode-session-config-options',
configOptions: [
{
id: 'model',
category: 'model',
currentValue: 'opencode/big-pickle',
options: [
{ value: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
{ value: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
]
}
]
};
}
return null;
},
close: async () => {}
};
const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });
expect(backend.getSessionModelsMetadata(sessionId)).toEqual({
availableModels: [
{ modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
{ modelId: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
],
currentModelId: 'opencode/big-pickle'
});
});
it('returns undefined session metadata when session/new omits models', async () => {
const backend = new AcpSdkBackend({ command: 'gemini' });
const backendInternal = backend as unknown as {
@@ -213,6 +252,67 @@ describe('AcpSdkBackend', () => {
});
});
it('captures and sets OpenCode thought-level config option', async () => {
const backend = new AcpSdkBackend({ command: 'opencode' });
const calls: Array<{ method: string; params: unknown }> = [];
const backendInternal = backend as unknown as {
transport: { sendRequest: (method: string, params: unknown) => Promise<unknown>; close: () => Promise<void> } | null;
};
backendInternal.transport = {
sendRequest: async (method, params) => {
calls.push({ method, params });
if (method === 'session/new') {
return {
sessionId: 's1',
configOptions: [{
id: 'effort',
name: 'Effort',
category: 'thought_level',
type: 'select',
currentValue: 'low',
options: [
{ value: 'low', name: 'Low' },
{ value: 'high', name: 'High' }
]
}]
};
}
if (method === 'session/set_config_option') {
return {
configOptions: [{
id: 'effort',
category: 'thought_level',
currentValue: 'high',
options: [{ value: 'high', name: 'High' }]
}]
};
}
return null;
},
close: async () => {}
};
await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });
expect(backend.getThoughtLevelConfigOption('s1')).toMatchObject({
id: 'effort',
currentValue: 'low',
options: [{ value: 'low', name: 'Low' }, { value: 'high', name: 'High' }]
});
await backend.setConfigOption('s1', 'effort', 'high');
expect(calls).toContainEqual({
method: 'session/set_config_option',
params: { sessionId: 's1', configId: 'effort', value: 'high' }
});
expect(backend.getThoughtLevelConfigOption('s1')).toMatchObject({
id: 'effort',
currentValue: 'high'
});
});
it('emits turn_complete after trailing tool updates from the same turn', async () => {
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
@@ -284,4 +384,65 @@ describe('AcpSdkBackend', () => {
'turn_complete'
]);
});
it('combines OpenCode usage_update and prompt usage into a usage message', async () => {
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
backendStatics.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 1;
backendStatics.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 50;
const backend = new AcpSdkBackend({ command: 'opencode' });
const backendInternal = backend as unknown as {
transport: {
sendRequest: (...args: unknown[]) => Promise<unknown>;
close: () => Promise<void>;
} | null;
handleSessionUpdate: (params: unknown) => void;
};
const messages: AgentMessage[] = [];
backendInternal.transport = {
sendRequest: async () => {
setTimeout(() => {
backendInternal.handleSessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: 'usage_update',
used: 13_879,
size: 65_536,
}
});
}, 0);
await sleep(5);
return {
stopReason: 'end_turn',
usage: {
totalTokens: 13_892,
inputTokens: 8_119,
outputTokens: 2,
thoughtTokens: 11,
cachedReadTokens: 5_760
}
};
},
close: async () => {}
};
await backend.prompt('session-1', [{ type: 'text', text: 'hello' }], (message) => {
messages.push(message);
});
expect(messages).toContainEqual({
type: 'usage',
inputTokens: 8_119,
outputTokens: 2,
cacheReadTokens: 5_760,
thoughtTokens: 11,
totalTokens: 13_892,
contextTokens: 13_879,
contextWindow: 65_536
});
});
});
+162 -6
View File
@@ -3,6 +3,7 @@ import type { AgentBackend, AgentMessage, AgentSessionConfig, PermissionRequest,
import { asString, isObject } from '@hapi/protocol';
import { AcpStdioTransport, type AcpStderrError } from './AcpStdioTransport';
import { AcpMessageHandler } from './AcpMessageHandler';
import { ACP_SESSION_UPDATE_TYPES } from './constants';
import { logger } from '@/ui/logger';
import { withRetry } from '@/utils/time';
import packageJson from '../../../../package.json';
@@ -11,6 +12,19 @@ type PendingPermission = {
resolve: (result: { outcome: { outcome: string; optionId?: string } }) => void;
};
type AcpPromptUsage = {
inputTokens: number;
outputTokens: number;
totalTokens?: number;
thoughtTokens?: number;
cacheReadTokens?: number;
};
type AcpUsageUpdate = {
contextTokens: number | undefined;
contextWindow: number | undefined;
};
export type AcpModelDescriptor = {
modelId: string;
name?: string;
@@ -21,17 +35,26 @@ export type AcpSessionModelsMetadata = {
currentModelId: string | null;
};
export type AcpConfigOptionDescriptor = {
id: string;
category?: string;
currentValue?: string;
options: Array<{ value: string; name?: string }>;
};
export class AcpSdkBackend implements AgentBackend {
private transport: AcpStdioTransport | null = null;
private permissionHandler: ((request: PermissionRequest) => void) | null = null;
private stderrErrorHandler: ((error: AcpStderrError) => void) | null = null;
private readonly pendingPermissions = new Map<string, PendingPermission>();
private readonly sessionModelsMetadata = new Map<string, AcpSessionModelsMetadata>();
private readonly sessionConfigOptions = new Map<string, AcpConfigOptionDescriptor[]>();
private messageHandler: AcpMessageHandler | null = null;
private activeSessionId: string | null = null;
private isProcessingMessage = false;
private responseCompleteResolvers: Array<() => void> = [];
private lastSessionUpdateAt = 0;
private latestUsageUpdate: AcpUsageUpdate | null = null;
/** Retry configuration for ACP initialization */
private static readonly INIT_RETRY_OPTIONS = {
@@ -120,7 +143,7 @@ export class AcpSdkBackend implements AgentBackend {
}
this.activeSessionId = sessionId;
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionMetadata(sessionId, response);
return sessionId;
}
@@ -146,7 +169,7 @@ export class AcpSdkBackend implements AgentBackend {
const loadedSessionId = isObject(response) ? asString(response.sessionId) : null;
const sessionId = loadedSessionId ?? config.sessionId;
this.activeSessionId = sessionId;
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionMetadata(sessionId, response);
return sessionId;
}
@@ -183,10 +206,29 @@ export class AcpSdkBackend implements AgentBackend {
} else {
// For other flavors (e.g. Gemini), if the response carries metadata,
// capture it. Missing fields are silently ignored.
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionMetadata(sessionId, response);
}
}
async setConfigOption(
sessionId: string,
configId: string,
value: string
): Promise<void> {
if (!this.transport) {
throw new Error('ACP transport not initialized');
}
await this.waitForResponseComplete();
const response = await this.transport.sendRequest('session/set_config_option', {
sessionId,
configId,
value
});
this.captureSessionMetadata(sessionId, response);
}
/**
* Returns the per-session models metadata captured from session/new (or
* session/load, or session/set_model). Returns undefined if the agent did
@@ -196,6 +238,10 @@ export class AcpSdkBackend implements AgentBackend {
return this.sessionModelsMetadata.get(sessionId);
}
getThoughtLevelConfigOption(sessionId: string): AcpConfigOptionDescriptor | undefined {
return this.sessionConfigOptions.get(sessionId)?.find((option) => option.category === 'thought_level');
}
async prompt(
sessionId: string,
content: PromptContent[],
@@ -219,7 +265,9 @@ export class AcpSdkBackend implements AgentBackend {
this.messageHandler = new AcpMessageHandler(onUpdate);
this.isProcessingMessage = true;
this.lastSessionUpdateAt = Date.now();
this.latestUsageUpdate = null;
let stopReason: string | null = null;
let promptUsage: AcpPromptUsage | null = null;
try {
// No timeout for prompt requests - they can run for extended periods
@@ -230,6 +278,7 @@ export class AcpSdkBackend implements AgentBackend {
}, { timeoutMs: Infinity });
stopReason = isObject(response) ? asString(response.stopReason) : null;
promptUsage = this.extractPromptUsage(response);
} finally {
await this.waitForSessionUpdateQuiet(
AcpSdkBackend.UPDATE_QUIET_PERIOD_MS,
@@ -237,6 +286,19 @@ export class AcpSdkBackend implements AgentBackend {
);
this.messageHandler?.drainBuffers();
try {
const latestUsageUpdate = this.readLatestUsageUpdate();
if (promptUsage) {
onUpdate({
type: 'usage',
inputTokens: promptUsage.inputTokens,
outputTokens: promptUsage.outputTokens,
totalTokens: promptUsage.totalTokens,
thoughtTokens: promptUsage.thoughtTokens,
cacheReadTokens: promptUsage.cacheReadTokens,
contextTokens: latestUsageUpdate ? latestUsageUpdate.contextTokens : undefined,
contextWindow: latestUsageUpdate ? latestUsageUpdate.contextWindow : undefined
});
}
if (stopReason) {
onUpdate({ type: 'turn_complete', stopReason });
}
@@ -336,9 +398,26 @@ export class AcpSdkBackend implements AgentBackend {
}
this.lastSessionUpdateAt = Date.now();
const update = params.update;
this.captureUsageUpdate(update);
this.messageHandler?.handleUpdate(update);
}
private captureUsageUpdate(update: unknown): void {
if (!isObject(update)) return;
if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.usageUpdate) return;
const contextTokens = this.asFiniteNumber(update.used);
const contextWindow = this.asFiniteNumber(update.size);
this.latestUsageUpdate = {
contextTokens: contextTokens ?? undefined,
contextWindow: contextWindow ?? undefined
};
}
private readLatestUsageUpdate(): AcpUsageUpdate | null {
return this.latestUsageUpdate;
}
private async waitForSessionUpdateQuiet(quietMs: number, timeoutMs: number): Promise<void> {
if (quietMs <= 0 || timeoutMs <= 0) {
return;
@@ -434,6 +513,64 @@ export class AcpSdkBackend implements AgentBackend {
});
}
private extractPromptUsage(response: unknown): AcpPromptUsage | null {
if (!isObject(response) || !isObject(response.usage)) return null;
const usage = response.usage;
const inputTokens = this.asFiniteNumber(usage.inputTokens ?? usage.input_tokens);
const outputTokens = this.asFiniteNumber(usage.outputTokens ?? usage.output_tokens);
if (inputTokens === null || outputTokens === null) return null;
return {
inputTokens,
outputTokens,
totalTokens: this.asFiniteNumber(usage.totalTokens ?? usage.total_tokens) ?? undefined,
thoughtTokens: this.asFiniteNumber(usage.thoughtTokens ?? usage.thought_tokens) ?? undefined,
cacheReadTokens: this.asFiniteNumber(
usage.cachedReadTokens
?? usage.cached_read_tokens
?? usage.cachedInputTokens
?? usage.cached_input_tokens
) ?? undefined
};
}
private asFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
private captureSessionMetadata(sessionId: string, response: unknown): void {
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionConfigOptions(sessionId, response);
}
private captureSessionConfigOptions(sessionId: string, response: unknown): void {
if (!isObject(response) || !Array.isArray(response.configOptions)) return;
const options = response.configOptions
.filter((entry): entry is Record<string, unknown> => isObject(entry))
.map((entry): AcpConfigOptionDescriptor | null => {
const id = asString(entry.id);
if (!id) return null;
const rawOptions = Array.isArray(entry.options) ? entry.options : [];
return {
id,
category: asString(entry.category) ?? undefined,
currentValue: asString(entry.currentValue) ?? undefined,
options: rawOptions
.filter((option): option is Record<string, unknown> => isObject(option))
.map((option) => ({
value: asString(option.value) ?? '',
name: asString(option.name) ?? undefined
}))
.filter((option) => option.value.length > 0)
};
})
.filter((entry): entry is AcpConfigOptionDescriptor => entry !== null);
this.sessionConfigOptions.set(sessionId, options);
}
/**
* Extract `availableModels` and `currentModelId` from an ACP response and
* store them keyed by sessionId. Both top-level and nested-under-`models`
@@ -442,6 +579,24 @@ export class AcpSdkBackend implements AgentBackend {
* expose model metadata (e.g. current Gemini ACP build) simply leave the
* cache untouched.
*/
private extractModelConfigOption(response: Record<string, unknown>): {
currentValue: string | null;
options: unknown[];
} | null {
if (!Array.isArray(response.configOptions)) return null;
for (const entry of response.configOptions) {
if (!isObject(entry)) continue;
if (asString(entry.category) !== 'model') continue;
return {
currentValue: asString(entry.currentValue),
options: Array.isArray(entry.options) ? entry.options : []
};
}
return null;
}
private captureSessionModelsMetadata(sessionId: string, response: unknown): void {
if (!isObject(response)) return;
@@ -451,16 +606,17 @@ export class AcpSdkBackend implements AgentBackend {
const nestedList = nested?.availableModels;
const nestedCurrent = nested?.currentModelId;
const configModelOption = this.extractModelConfigOption(response);
const rawModels = Array.isArray(directList)
? directList
: Array.isArray(nestedList)
? nestedList
: null;
: configModelOption?.options ?? null;
const rawCurrent = typeof directCurrent === 'string'
? directCurrent
: typeof nestedCurrent === 'string'
? nestedCurrent
: null;
: configModelOption?.currentValue ?? null;
if (rawModels === null && rawCurrent === null) {
return;
@@ -470,7 +626,7 @@ export class AcpSdkBackend implements AgentBackend {
if (Array.isArray(rawModels)) {
for (const entry of rawModels) {
if (!isObject(entry)) continue;
const modelId = asString(entry.modelId);
const modelId = asString(entry.modelId) ?? asString(entry.value);
if (!modelId) continue;
const name = asString(entry.name) ?? undefined;
availableModels.push(name ? { modelId, name } : { modelId });
+2 -1
View File
@@ -3,5 +3,6 @@ export const ACP_SESSION_UPDATE_TYPES = {
agentThoughtChunk: 'agent_thought_chunk',
toolCall: 'tool_call',
toolCallUpdate: 'tool_call_update',
plan: 'plan'
plan: 'plan',
usageUpdate: 'usage_update'
} as const;
+28
View File
@@ -49,4 +49,32 @@ describe('convertAgentMessage', () => {
id: 'reasoning-stream-1'
});
});
it('converts usage messages into token_count payloads', () => {
const converted = convertAgentMessage({
type: 'usage',
inputTokens: 8_119,
outputTokens: 2,
cacheReadTokens: 5_760,
thoughtTokens: 11,
totalTokens: 13_892,
contextTokens: 13_879,
contextWindow: 65_536
});
expect(converted).toEqual({
type: 'token_count',
info: {
total: {
inputTokens: 8119,
outputTokens: 2,
cachedInputTokens: 5760,
thoughtTokens: 11,
totalTokens: 13892
},
contextTokens: 13879,
modelContextWindow: 65536
}
});
});
});
+29
View File
@@ -4,6 +4,20 @@ import type { AgentMessage, PlanItem } from './types';
export type CodexMessage =
| { type: 'message'; message: string }
| { type: 'reasoning'; message: string; id: string }
| {
type: 'token_count';
info: {
total: {
inputTokens: number;
outputTokens: number;
totalTokens?: number;
thoughtTokens?: number;
cachedInputTokens?: number;
};
contextTokens?: number;
modelContextWindow?: number;
};
}
| {
type: 'tool-call';
name: string;
@@ -29,6 +43,21 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null
// the wire-level CodexMessage uses `message` to match the
// existing reasoning format emitted by the Codex path.
return { type: 'reasoning', message: message.text, id: message.id ?? randomUUID() };
case 'usage':
return {
type: 'token_count',
info: {
total: {
inputTokens: message.inputTokens,
outputTokens: message.outputTokens,
totalTokens: message.totalTokens,
thoughtTokens: message.thoughtTokens,
cachedInputTokens: message.cacheReadTokens
},
contextTokens: message.contextTokens,
modelContextWindow: message.contextWindow
}
};
case 'tool_call':
return {
type: 'tool-call',
+23
View File
@@ -23,6 +23,10 @@ describe('sessionConfigRpc', () => {
expect(() => resolveSessionConfigPermissionMode('bypassPermissions', 'gemini')).toThrow('Invalid permission mode')
})
it('accepts OpenCode plan permission mode', () => {
expect(resolveSessionConfigPermissionMode('plan', 'opencode')).toBe('plan')
})
it('accepts null model for agents that support model config', () => {
expect(resolveNullableSessionModel(null)).toBeNull()
})
@@ -68,6 +72,25 @@ describe('sessionConfigRpc', () => {
expect(onApply).toHaveBeenCalledWith({})
})
it('applies nullable model reasoning effort when supported', async () => {
const harness = createRpcHarness()
const onApply = vi.fn()
registerSessionConfigRpc({
rpcHandlerManager: harness.rpcHandlerManager,
flavor: 'opencode',
modelReasoningEffortMode: 'nullable',
onApply
})
const result = await harness.getHandler()({ modelReasoningEffort: 'high' }) as { applied: Record<string, unknown> }
expect(result.applied.modelReasoningEffort).toBe('high')
expect(onApply).toHaveBeenCalledWith({ modelReasoningEffort: 'high' })
})
it('rejects model config for agents configured to reject model changes', async () => {
const harness = createRpcHarness()
+15 -1
View File
@@ -7,12 +7,14 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
type SessionConfigState<TPermissionMode extends PermissionMode = PermissionMode> = {
permissionMode?: TPermissionMode
model?: string | null
modelReasoningEffort?: string | null
}
type RegisterSessionConfigRpcOptions<TPermissionMode extends PermissionMode = PermissionMode> = {
rpcHandlerManager: RpcHandlerManager
flavor: AgentFlavor
modelMode?: 'nullable' | 'ignore' | 'reject'
modelReasoningEffortMode?: 'nullable' | 'ignore' | 'reject'
appliedFallback?: () => Record<string, unknown>
onApply: (config: SessionConfigState<TPermissionMode>) => void
onAfterApply?: () => void
@@ -43,6 +45,7 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
rpcHandlerManager,
flavor,
modelMode = 'reject',
modelReasoningEffortMode = 'reject',
appliedFallback,
onApply,
onAfterApply
@@ -52,7 +55,7 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
throw new Error('Invalid session config payload')
}
const config = payload as { permissionMode?: unknown; model?: unknown }
const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown }
const applied: Record<string, unknown> = {}
const next: SessionConfigState<TPermissionMode> = {}
@@ -71,6 +74,17 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
}
}
if (config.modelReasoningEffort !== undefined) {
if (modelReasoningEffortMode === 'reject') {
throw new Error('Invalid model reasoning effort')
}
if (modelReasoningEffortMode === 'nullable') {
next.modelReasoningEffort = resolveNullableSessionModel(config.modelReasoningEffort)
applied.modelReasoningEffort = next.modelReasoningEffort
}
}
onApply(next)
onAfterApply?.()
+19
View File
@@ -33,6 +33,16 @@ export type AgentMessage =
| { type: 'reasoning'; text: string; id?: string; live?: boolean }
| { type: 'tool_call'; id: string; name: string; input: unknown; status: 'pending' | 'in_progress' | 'completed' | 'failed' }
| { type: 'tool_result'; id: string; output: unknown; status: 'completed' | 'failed' }
| {
type: 'usage';
inputTokens: number;
outputTokens: number;
totalTokens?: number;
thoughtTokens?: number;
cacheReadTokens?: number;
contextTokens?: number;
contextWindow?: number;
}
| { type: 'plan'; items: PlanItem[] }
| { type: 'turn_complete'; stopReason: string }
| { type: 'error'; message: string };
@@ -68,11 +78,20 @@ export type AgentSessionModelsMetadata = {
currentModelId: string | null;
};
export type AgentSessionConfigOptionDescriptor = {
id: string;
category?: string;
currentValue?: string;
options: Array<{ value: string; name?: string }>;
};
export interface AgentBackend {
initialize(): Promise<void>;
newSession(config: AgentSessionConfig): Promise<string>;
setModel?(sessionId: string, modelId: string, opts?: { flavor?: AgentFlavor }): Promise<void>;
setConfigOption?(sessionId: string, configId: string, value: string): Promise<void>;
getSessionModelsMetadata?(sessionId: string): AgentSessionModelsMetadata | undefined;
getThoughtLevelConfigOption?(sessionId: string): AgentSessionConfigOptionDescriptor | undefined;
prompt(sessionId: string, content: PromptContent[], onUpdate: (msg: AgentMessage) => void): Promise<void>;
cancelPrompt(sessionId: string): Promise<void>;
respondToPermission(sessionId: string, request: PermissionRequest, response: PermissionResponse): Promise<void>;
@@ -26,6 +26,13 @@ describe('parseRemoteAgentCommandOptions', () => {
], OPENCODE_PERMISSION_MODES).permissionMode).toBe('default')
})
it('accepts OpenCode plan permission mode', () => {
expect(parseRemoteAgentCommandOptions([
'--permission-mode',
'plan'
], OPENCODE_PERMISSION_MODES).permissionMode).toBe('plan')
})
it('keeps current unknown-arg behavior by ignoring unrecognized flags', () => {
expect(parseRemoteAgentCommandOptions([
'--unknown',
@@ -49,8 +56,16 @@ describe('parseRemoteAgentCommandOptions', () => {
], GEMINI_PERMISSION_MODES)).toThrow('Invalid --permission-mode value')
})
it('parses model reasoning effort', () => {
expect(parseRemoteAgentCommandOptions([
'--model-reasoning-effort',
'high'
], OPENCODE_PERMISSION_MODES).modelReasoningEffort).toBe('high')
})
it('requires values for resume and model flags', () => {
expect(() => parseRemoteAgentCommandOptions(['--resume'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --resume value')
expect(() => parseRemoteAgentCommandOptions(['--model'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model value')
expect(() => parseRemoteAgentCommandOptions(['--model-reasoning-effort'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model-reasoning-effort value')
})
})
+7
View File
@@ -5,6 +5,7 @@ export type RemoteAgentCommandOptions<TPermissionMode extends PermissionMode> =
startingMode?: 'local' | 'remote'
permissionMode?: TPermissionMode
model?: string
modelReasoningEffort?: string
resumeSessionId?: string
}
@@ -47,6 +48,12 @@ export function parseRemoteAgentCommandOptions<TPermissionMode extends Permissio
throw new Error('Missing --model value')
}
options.model = model
} else if (arg === '--model-reasoning-effort') {
const modelReasoningEffort = args[++i]
if (!modelReasoningEffort) {
throw new Error('Missing --model-reasoning-effort value')
}
options.modelReasoningEffort = modelReasoningEffort
}
}
+2
View File
@@ -232,6 +232,8 @@ class GeminiRemoteLauncher 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;
+2
View File
@@ -228,6 +228,8 @@ class KimiRemoteLauncher 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;
+20 -4
View File
@@ -74,16 +74,32 @@ describe('listOpencodeModelsForCwd', () => {
expect(closeMock).toHaveBeenCalled()
})
it('returns empty availableModels when session/new omits the models block', async () => {
it('reads availableModels from configOptions when session/new omits the models block', async () => {
sendRequestMock
.mockResolvedValueOnce({ protocolVersion: 1 })
.mockResolvedValueOnce({ sessionId: 'sess-2' })
.mockResolvedValueOnce({
sessionId: 'sess-2',
configOptions: [
{
id: 'model',
category: 'model',
currentValue: 'opencode/big-pickle',
options: [
{ value: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
{ value: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
]
}
]
})
const result = await listOpencodeModelsForCwd('/tmp/proj')
expect(result.success).toBe(true)
expect(result.availableModels).toEqual([])
expect(result.currentModelId).toBeNull()
expect(result.availableModels).toEqual([
{ modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
{ modelId: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
])
expect(result.currentModelId).toBe('opencode/big-pickle')
})
it('reads availableModels from top-level fields too (alternate response shape)', async () => {
+22 -3
View File
@@ -25,7 +25,7 @@ function normalizeAvailableModels(rawModels: unknown): OpencodeModelSummary[] {
const out: OpencodeModelSummary[] = [];
for (const entry of rawModels) {
if (!isObject(entry)) continue;
const modelId = asString(entry.modelId);
const modelId = asString(entry.modelId) ?? asString(entry.value);
if (!modelId) continue;
const name = asString(entry.name) ?? undefined;
out.push(name ? { modelId, name } : { modelId });
@@ -33,6 +33,24 @@ function normalizeAvailableModels(rawModels: unknown): OpencodeModelSummary[] {
return out;
}
function extractModelConfigOption(response: Record<string, unknown>): {
currentValue: string | null;
options: unknown[];
} | null {
if (!Array.isArray(response.configOptions)) return null;
for (const entry of response.configOptions) {
if (!isObject(entry)) continue;
if (asString(entry.category) !== 'model') continue;
return {
currentValue: asString(entry.currentValue),
options: Array.isArray(entry.options) ? entry.options : []
};
}
return null;
}
function extractModelsFromResponse(response: unknown): {
availableModels: OpencodeModelSummary[];
currentModelId: string | null;
@@ -47,16 +65,17 @@ function extractModelsFromResponse(response: unknown): {
const nestedList = nested?.availableModels;
const nestedCurrent = nested?.currentModelId;
const configModelOption = extractModelConfigOption(response);
const rawModels = Array.isArray(directList)
? directList
: Array.isArray(nestedList)
? nestedList
: null;
: configModelOption?.options ?? null;
const rawCurrent = typeof directCurrent === 'string'
? directCurrent
: typeof nestedCurrent === 'string'
? nestedCurrent
: null;
: configModelOption?.currentValue ?? null;
return {
availableModels: normalizeAvailableModels(rawModels),
+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.
`);
+11
View File
@@ -60,6 +60,17 @@ describe('buildCliArgs', () => {
expect(args).toContain('ollama/exaone:4.5-33b-q8')
})
it('passes --model-reasoning-effort through for opencode', () => {
const args = buildCliArgs('opencode', {
directory: '/tmp',
modelReasoningEffort: 'high',
})
expect(args).toContain('--model-reasoning-effort')
expect(args).toContain('high')
})
it('validates all known permission modes', () => {
for (const mode of ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'ask', 'read-only', 'safe-yolo', 'yolo']) {
const args = buildCliArgs('claude', {
+1 -1
View File
@@ -933,7 +933,7 @@ export function buildCliArgs(
if (options.effort && agent === 'claude') {
args.push('--effort', options.effort);
}
if (options.modelReasoningEffort && agent === 'codex') {
if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) {
args.push('--model-reasoning-effort', options.modelReasoningEffort);
}
if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) {
+28
View File
@@ -291,6 +291,34 @@ describe('session model', () => {
})
})
it('rejects active session config updates when CLI ignores requested keys', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{ of: () => ({ to: () => ({ emit() {} }) }) } as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-config-ignored',
{ path: '/tmp/project', host: 'localhost', flavor: 'opencode' },
null,
'default'
)
engine.handleSessionAlive({ sid: session.id, time: Date.now() })
;(engine as any).rpcGateway.requestSessionConfig = async () => ({ applied: {} })
await expect(
engine.applySessionConfig(session.id, { modelReasoningEffort: 'high' })
).rejects.toThrow('Session did not apply modelReasoningEffort')
expect(engine.getSession(session.id)?.modelReasoningEffort).toBeNull()
} finally {
engine.stop()
}
})
it('touches session updatedAt when web sends a message through sync engine', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
+7
View File
@@ -459,6 +459,13 @@ export class SyncEngine {
throw new Error('Missing applied session config')
}
const requestedKeys = Object.keys(config) as Array<keyof typeof config>
for (const key of requestedKeys) {
if (!(key in applied)) {
throw new Error(`Session did not apply ${key}`)
}
}
this.sessionCache.applySessionConfig(sessionId, applied)
}
+71 -3
View File
@@ -167,7 +167,7 @@ describe('sessions routes', () => {
])
})
it('rejects model reasoning effort changes for non-Codex sessions', async () => {
it('rejects model reasoning effort changes for unsupported sessions', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
@@ -185,7 +185,7 @@ describe('sessions routes', () => {
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'Model reasoning effort is only supported for Codex sessions'
error: 'Model reasoning effort is only supported for Codex and OpenCode sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
@@ -208,7 +208,7 @@ describe('sessions routes', () => {
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'Model reasoning effort can only be changed for remote Codex sessions'
error: 'Model reasoning effort can only be changed for remote sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
@@ -229,6 +229,31 @@ describe('sessions routes', () => {
])
})
it('applies model reasoning effort changes for remote OpenCode sessions', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'opencode'
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/model-reasoning-effort', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ modelReasoningEffort: 'high' })
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(applySessionConfigCalls).toEqual([
['session-1', { modelReasoningEffort: 'high' }]
])
})
it('applies model changes for remote Codex sessions', async () => {
const { app, applySessionConfigCalls } = createApp(createSession())
@@ -442,6 +467,49 @@ describe('sessions routes', () => {
expect(response.status).toBe(400)
})
it('rejects OpenCode plan mode changes for local sessions', async () => {
const session = createSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' },
agentState: {
controlledByUser: true,
requests: {},
completedRequests: {}
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/permission-mode', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'plan' })
})
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'OpenCode plan mode is only supported for remote sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
it('applies OpenCode plan mode changes for remote sessions', async () => {
const session = createSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/permission-mode', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'plan' })
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(applySessionConfigCalls).toEqual([
['session-1', { permissionMode: 'plan' }]
])
})
it('applies permission mode changes for inactive sessions', async () => {
const session = createSession({
active: false,
+6 -3
View File
@@ -279,6 +279,9 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
if (!isPermissionModeAllowedForFlavor(mode, flavor)) {
return c.json({ error: 'Invalid permission mode for session flavor' }, 400)
}
if (flavor === 'opencode' && mode === 'plan' && sessionResult.session.agentState?.controlledByUser === true) {
return c.json({ error: 'OpenCode plan mode is only supported for remote sessions' }, 409)
}
try {
await engine.applySessionConfig(sessionResult.sessionId, { permissionMode: mode })
@@ -369,11 +372,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
}
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
if (flavor !== 'codex') {
return c.json({ error: 'Model reasoning effort is only supported for Codex sessions' }, 400)
if (flavor !== 'codex' && flavor !== 'opencode') {
return c.json({ error: 'Model reasoning effort is only supported for Codex and OpenCode sessions' }, 400)
}
if (sessionResult.session.agentState?.controlledByUser === true) {
return c.json({ error: 'Model reasoning effort can only be changed for remote Codex sessions' }, 409)
return c.json({ error: 'Model reasoning effort can only be changed for remote sessions' }, 409)
}
const body = await c.req.json().catch(() => null)
+1 -1
View File
@@ -26,7 +26,7 @@ export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number]
export const KIMI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const
export type KimiPermissionMode = typeof KIMI_PERMISSION_MODES[number]
export const OPENCODE_PERMISSION_MODES = ['default', 'yolo'] as const
export const OPENCODE_PERMISSION_MODES = ['default', 'plan', 'yolo'] as const
export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number]
export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'yolo'] as const
+36
View File
@@ -641,6 +641,42 @@ describe('normalizeDecryptedMessage', () => {
})
})
it('normalizes token_count payloads with explicit contextTokens', () => {
const message = makeMessage({
role: 'agent',
content: {
type: 'codex',
data: {
type: 'token_count',
info: {
total: {
inputTokens: 8_119,
outputTokens: 2,
cachedInputTokens: 5_760,
thoughtTokens: 11,
totalTokens: 13_892
},
contextTokens: 13_879,
modelContextWindow: 65_536
}
}
}
})
const normalized = normalizeDecryptedMessage(message)
expect(normalized).toMatchObject({
role: 'event',
usage: {
input_tokens: 8119,
output_tokens: 2,
cache_read_input_tokens: 5760,
context_tokens: 13879,
context_window: 65536
}
})
})
it('normalizes Codex context_compacted as a compact event', () => {
const message = makeMessage({
role: 'agent',
+6 -1
View File
@@ -88,7 +88,12 @@ function normalizeCodexTokenUsage(value: unknown, data?: Record<string, unknown>
?? usageSource.cacheReadInputTokens
?? usageSource.cache_read_input_tokens
) ?? undefined,
context_tokens: inputTokens,
context_tokens: asNumber(
info.contextTokens
?? info.context_tokens
?? usageSource.contextTokens
?? usageSource.context_tokens
) ?? inputTokens,
context_window: asNumber(info.modelContextWindow ?? info.model_context_window) ?? undefined,
thread_id: asString(
data?.thread_id
@@ -298,7 +298,9 @@ export function HappyComposer(props: {
[agentFlavor, model, availableModelOptions]
)
const codexReasoningEffortOptions = useMemo(
() => agentFlavor === 'codex' ? getCodexComposerReasoningEffortOptions(modelReasoningEffort) : [],
() => agentFlavor === 'codex' || agentFlavor === 'opencode'
? getCodexComposerReasoningEffortOptions(modelReasoningEffort, agentFlavor)
: [],
[agentFlavor, modelReasoningEffort]
)
const claudeEffortOptions = useMemo(
@@ -205,7 +205,7 @@ export function StatusBar(props: {
const collaborationModeLabel = displayCollaborationMode
? getCodexCollaborationModeLabel(displayCollaborationMode)
: null
const codexReasoningLabel = props.agentFlavor === 'codex'
const codexReasoningLabel = (props.agentFlavor === 'codex' || props.agentFlavor === 'opencode')
? formatCodexReasoningLabel(props.modelReasoningEffort)
: null
const codexFastMode = props.agentFlavor === 'codex'
@@ -4,11 +4,13 @@ export type CodexComposerReasoningEffortOption = {
}
const CODEX_REASONING_EFFORT_PRESETS = ['low', 'medium', 'high', 'xhigh'] as const
const CODEX_REASONING_EFFORT_LABELS: Record<(typeof CODEX_REASONING_EFFORT_PRESETS)[number], string> = {
const OPENCODE_REASONING_EFFORT_PRESETS = ['low', 'medium', 'high', 'max'] as const
const CODEX_REASONING_EFFORT_LABELS: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'XHigh'
xhigh: 'XHigh',
max: 'Max'
}
function normalizeCodexComposerReasoningEffort(effort?: string | null): string | null {
@@ -25,15 +27,19 @@ function formatCodexReasoningEffortLabel(effort: string): string {
?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}`
}
export function getCodexComposerReasoningEffortOptions(currentEffort?: string | null): CodexComposerReasoningEffortOption[] {
export function getCodexComposerReasoningEffortOptions(
currentEffort?: string | null,
flavor?: string | null
): CodexComposerReasoningEffortOption[] {
const normalizedCurrentEffort = normalizeCodexComposerReasoningEffort(currentEffort)
const presets = flavor === 'opencode' ? OPENCODE_REASONING_EFFORT_PRESETS : CODEX_REASONING_EFFORT_PRESETS
const options: CodexComposerReasoningEffortOption[] = [
{ value: null, label: 'Default' }
]
if (
normalizedCurrentEffort
&& !CODEX_REASONING_EFFORT_PRESETS.includes(normalizedCurrentEffort as typeof CODEX_REASONING_EFFORT_PRESETS[number])
&& !(presets as readonly string[]).includes(normalizedCurrentEffort)
) {
options.push({
value: normalizedCurrentEffort,
@@ -41,7 +47,7 @@ export function getCodexComposerReasoningEffortOptions(currentEffort?: string |
})
}
options.push(...CODEX_REASONING_EFFORT_PRESETS.map((effort) => ({
options.push(...presets.map((effort) => ({
value: effort,
label: CODEX_REASONING_EFFORT_LABELS[effort]
})))
@@ -10,7 +10,7 @@ export function ReasoningEffortSelector(props: {
}) {
const { t } = useTranslation()
if (props.agent !== 'codex') {
if (props.agent !== 'codex' && props.agent !== 'opencode') {
return null
}
@@ -26,7 +26,7 @@ export function ReasoningEffortSelector(props: {
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{CODEX_REASONING_EFFORT_OPTIONS.map((option) => (
{CODEX_REASONING_EFFORT_OPTIONS.filter((option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max').map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
+2 -1
View File
@@ -73,6 +73,7 @@ export function NewSession(props: {
useEffect(() => {
setModel('auto')
setEffort('auto')
setModelReasoningEffort('default')
}, [agent])
useEffect(() => {
@@ -341,7 +342,7 @@ export function NewSession(props: {
? (opencodeSelectedModel ?? undefined)
: (model !== 'auto' ? model : undefined)
const resolvedEffort = agent === 'claude' && effort !== 'auto' ? effort : undefined
const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default'
const resolvedModelReasoningEffort = (agent === 'codex' || agent === 'opencode') && modelReasoningEffort !== 'default'
? modelReasoningEffort
: undefined
const result = await spawnSession({
+2 -1
View File
@@ -8,7 +8,7 @@ import type { AgentFlavor } from '@hapi/protocol'
export type AgentType = AgentFlavor
export type SessionType = 'simple' | 'worktree'
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh'
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
export type ClaudeEffort = 'auto' | 'medium' | 'high' | 'max'
function modelPresetOptions<TModel extends string>(
@@ -43,6 +43,7 @@ export const CODEX_REASONING_EFFORT_OPTIONS: { value: CodexReasoningEffort; labe
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'XHigh' },
{ value: 'max', label: 'Max' },
]
export const CLAUDE_EFFORT_OPTIONS: { value: ClaudeEffort; label: string }[] = [
+2 -2
View File
@@ -623,7 +623,7 @@ export function SessionChat(props: {
collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined}
threadGoal={reduced.latestGoal}
model={props.session.model}
modelReasoningEffort={agentFlavor === 'codex' ? props.session.modelReasoningEffort : undefined}
modelReasoningEffort={agentFlavor === 'codex' || agentFlavor === 'opencode' ? props.session.modelReasoningEffort : undefined}
effort={props.session.effort}
agentFlavor={agentFlavor}
availableModelOptions={
@@ -658,7 +658,7 @@ export function SessionChat(props: {
: handleModelChange
}
onModelReasoningEffortChange={
agentFlavor === 'codex' && props.session.active && !controlledByUser
(agentFlavor === 'codex' || agentFlavor === 'opencode') && props.session.active && !controlledByUser
? handleModelReasoningEffortChange
: undefined
}
+4 -4
View File
@@ -106,11 +106,11 @@ export function useSessionActions(
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
if (agentFlavor !== 'codex') {
throw new Error('Model reasoning effort is only supported for Codex sessions')
if (agentFlavor !== 'codex' && agentFlavor !== 'opencode') {
throw new Error('Model reasoning effort is only supported for Codex and OpenCode sessions')
}
if (!codexCollaborationModeSupported) {
throw new Error('Model reasoning effort is only supported for remote Codex sessions')
if (agentFlavor === 'codex' && !codexCollaborationModeSupported) {
throw new Error('Model reasoning effort is only supported for remote sessions')
}
await api.setModelReasoningEffort(sessionId, modelReasoningEffort)
},
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { getOpencodeModelsRefetchInterval, shouldRetryOpencodeModelsQuery } from './useOpencodeModels'
describe('useOpencodeModels retry policy', () => {
it('retries early failures while the session model RPC may still be registering', () => {
expect(shouldRetryOpencodeModelsQuery(0)).toBe(true)
expect(shouldRetryOpencodeModelsQuery(2)).toBe(true)
expect(shouldRetryOpencodeModelsQuery(3)).toBe(false)
})
it('polls briefly until OpenCode session models are discovered', () => {
expect(getOpencodeModelsRefetchInterval(true, undefined, 0)).toBe(1000)
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] }, 1)).toBe(1000)
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' }, 2)).toBe(1000)
})
it('stops polling once model options are available or the query is disabled', () => {
expect(getOpencodeModelsRefetchInterval(true, {
success: true,
availableModels: [{ modelId: 'provider/model', name: 'Provider Model' }],
currentModelId: 'provider/model'
}, 1)).toBe(false)
expect(getOpencodeModelsRefetchInterval(false, undefined, 0)).toBe(false)
})
it('stops polling after the discovery poll cap', () => {
expect(getOpencodeModelsRefetchInterval(true, undefined, 10)).toBe(false)
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] }, 10)).toBe(false)
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' }, 10)).toBe(false)
})
})
+30 -1
View File
@@ -1,8 +1,32 @@
import { useQuery } from '@tanstack/react-query'
import type { OpencodeModelsResponse } from '@hapi/protocol/apiTypes'
import type { ApiClient } from '@/api/client'
import type { OpencodeModelSummary } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
export function shouldRetryOpencodeModelsQuery(failureCount: number): boolean {
return failureCount < 3
}
const MAX_OPENCODE_MODEL_DISCOVERY_POLLS = 10
export function getOpencodeModelsRefetchInterval(
enabled: boolean,
data: OpencodeModelsResponse | undefined,
pollCount: number
): 1000 | false {
if (!enabled || pollCount >= MAX_OPENCODE_MODEL_DISCOVERY_POLLS) {
return false
}
if (!data) {
return 1000
}
if (data.success === false) {
return 1000
}
return (data.availableModels?.length ?? 0) > 0 ? false : 1000
}
export function useOpencodeModels(args: {
api: ApiClient | null
sessionId?: string | null
@@ -31,7 +55,12 @@ export function useOpencodeModels(args: {
},
enabled,
staleTime: 30_000,
retry: false,
retry: (failureCount) => shouldRetryOpencodeModelsQuery(failureCount),
refetchInterval: (query) => getOpencodeModelsRefetchInterval(
enabled,
query.state.data as OpencodeModelsResponse | undefined,
query.state.dataUpdateCount + query.state.errorUpdateCount
),
})
return {