diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index d167e2d9..274938f8 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -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; close: () => Promise } | 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; close: () => Promise } | 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; + close: () => Promise; + } | 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 + }); + }); }); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index c15074bd..0b4d98bb 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -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(); private readonly sessionModelsMetadata = new Map(); + private readonly sessionConfigOptions = new Map(); 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 { + 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 { 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 => 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 => 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): { + 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 }); diff --git a/cli/src/agent/backends/acp/constants.ts b/cli/src/agent/backends/acp/constants.ts index 2e4a7c71..66c7d9f3 100644 --- a/cli/src/agent/backends/acp/constants.ts +++ b/cli/src/agent/backends/acp/constants.ts @@ -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; diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index 628c7495..0aeb31f3 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -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 + } + }); + }); }); diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts index c3313a7e..4e1dbed5 100644 --- a/cli/src/agent/messageConverter.ts +++ b/cli/src/agent/messageConverter.ts @@ -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', diff --git a/cli/src/agent/sessionConfigRpc.test.ts b/cli/src/agent/sessionConfigRpc.test.ts index e5b8e497..ef0d41a9 100644 --- a/cli/src/agent/sessionConfigRpc.test.ts +++ b/cli/src/agent/sessionConfigRpc.test.ts @@ -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 } + + 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() diff --git a/cli/src/agent/sessionConfigRpc.ts b/cli/src/agent/sessionConfigRpc.ts index 7f9c2f46..c8e72e79 100644 --- a/cli/src/agent/sessionConfigRpc.ts +++ b/cli/src/agent/sessionConfigRpc.ts @@ -7,12 +7,14 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' type SessionConfigState = { permissionMode?: TPermissionMode model?: string | null + modelReasoningEffort?: string | null } type RegisterSessionConfigRpcOptions = { rpcHandlerManager: RpcHandlerManager flavor: AgentFlavor modelMode?: 'nullable' | 'ignore' | 'reject' + modelReasoningEffortMode?: 'nullable' | 'ignore' | 'reject' appliedFallback?: () => Record onApply: (config: SessionConfigState) => void onAfterApply?: () => void @@ -43,6 +45,7 @@ export function registerSessionConfigRpc rpcHandlerManager, flavor, modelMode = 'reject', + modelReasoningEffortMode = 'reject', appliedFallback, onApply, onAfterApply @@ -52,7 +55,7 @@ export function registerSessionConfigRpc 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 = {} const next: SessionConfigState = {} @@ -71,6 +74,17 @@ export function registerSessionConfigRpc } } + + 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?.() diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index 5b0e5dbe..03d85431 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -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; newSession(config: AgentSessionConfig): Promise; setModel?(sessionId: string, modelId: string, opts?: { flavor?: AgentFlavor }): Promise; + setConfigOption?(sessionId: string, configId: string, value: string): Promise; getSessionModelsMetadata?(sessionId: string): AgentSessionModelsMetadata | undefined; + getThoughtLevelConfigOption?(sessionId: string): AgentSessionConfigOptionDescriptor | undefined; prompt(sessionId: string, content: PromptContent[], onUpdate: (msg: AgentMessage) => void): Promise; cancelPrompt(sessionId: string): Promise; respondToPermission(sessionId: string, request: PermissionRequest, response: PermissionResponse): Promise; diff --git a/cli/src/commands/agentCommandOptions.test.ts b/cli/src/commands/agentCommandOptions.test.ts index b8577d14..7561774f 100644 --- a/cli/src/commands/agentCommandOptions.test.ts +++ b/cli/src/commands/agentCommandOptions.test.ts @@ -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') }) }) diff --git a/cli/src/commands/agentCommandOptions.ts b/cli/src/commands/agentCommandOptions.ts index ecfbb9c6..0e2e271b 100644 --- a/cli/src/commands/agentCommandOptions.ts +++ b/cli/src/commands/agentCommandOptions.ts @@ -5,6 +5,7 @@ export type RemoteAgentCommandOptions = startingMode?: 'local' | 'remote' permissionMode?: TPermissionMode model?: string + modelReasoningEffort?: string resumeSessionId?: string } @@ -47,6 +48,12 @@ export function parseRemoteAgentCommandOptions { 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 () => { diff --git a/cli/src/modules/common/opencodeModels.ts b/cli/src/modules/common/opencodeModels.ts index 5e8fc4c2..5d065188 100644 --- a/cli/src/modules/common/opencodeModels.ts +++ b/cli/src/modules/common/opencodeModels.ts @@ -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): { + 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), diff --git a/cli/src/opencode/loop.ts b/cli/src/opencode/loop.ts index afb3701b..a9c5d1e9 100644 --- a/cli/src/opencode/loop.ts +++ b/cli/src/opencode/loop.ts @@ -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 { @@ -40,7 +42,8 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise { 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 { hookServer: opts.hookServer, hookUrl: opts.hookUrl }), - runRemote: (instance) => opencodeRemoteLauncher(instance), + runRemote: (instance) => opencodeRemoteLauncher(instance, { + onReasoningEffortRollback: opts.onReasoningEffortRollback + }), onSessionReady: opts.onSessionReady }); } diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index 02fc9b2b..f606f5f0 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -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) + setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise), + setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise), + 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((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((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 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 = []; + + 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; expect(result).toEqual({ - success: true, - availableModels: [], - currentModelId: null + success: false, + error: 'OpenCode model metadata is not available' }); }); diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 38ec88b8..fc1e1f77 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -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 { - const launcher = new OpencodeRemoteLauncher(session); + const launcher = new OpencodeRemoteLauncher(session, options); return launcher.launch(); } diff --git a/cli/src/opencode/runOpencode.test.ts b/cli/src/opencode/runOpencode.test.ts index 6f94b960..0b7a3aca 100644 --- a/cli/src/opencode/runOpencode.test.ts +++ b/cli/src/opencode/runOpencode.test.ts @@ -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; } + 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; + const applied = result.applied as Record; + + 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; + const applied = result.applied as Record; + + 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' }); diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index fa047b8c..b8eeca2a 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -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((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(); diff --git a/cli/src/opencode/session.ts b/cli/src/opencode/session.ts index 8ffae6d3..7ab14cb4 100644 --- a/cli/src/opencode/session.ts +++ b/cli/src/opencode/session.ts @@ -28,6 +28,7 @@ export class OpencodeSession extends AgentSessionBase { startedBy: 'runner' | 'terminal'; startingMode: 'local' | 'remote'; permissionMode?: PermissionMode; + modelReasoningEffort?: string | null; }) { super({ api: opts.api, @@ -44,12 +45,14 @@ export class OpencodeSession extends AgentSessionBase { ...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 { this.model = model; }; + setModelReasoningEffort = (modelReasoningEffort: string | null): void => { + this.modelReasoningEffort = modelReasoningEffort; + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; diff --git a/cli/src/opencode/types.ts b/cli/src/opencode/types.ts index 800292a6..60682bda 100644 --- a/cli/src/opencode/types.ts +++ b/cli/src/opencode/types.ts @@ -5,6 +5,7 @@ export type PermissionMode = OpencodePermissionMode; export interface OpencodeMode { permissionMode: PermissionMode; model?: string; + modelReasoningEffort?: string | null; } export type OpencodeHookEvent = { diff --git a/cli/src/opencode/utils/permissionHandler.test.ts b/cli/src/opencode/utils/permissionHandler.test.ts new file mode 100644 index 00000000..cb02314b --- /dev/null +++ b/cli/src/opencode/utils/permissionHandler.test.ts @@ -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; + completedRequests: Record; +}; + +function createHarness(getPermissionMode: () => 'default' | 'plan' | 'yolo' = () => 'default') { + let agentState: FakeAgentState = { + requests: {}, + completedRequests: {} + }; + const rpcHandlers = new Map Promise | 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) { + 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 { + await Promise.resolve(); + await Promise.resolve(); +} + +function buildRequest(overrides?: Partial): 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' + } + }); + }); +}); diff --git a/cli/src/opencode/utils/permissionHandler.ts b/cli/src/opencode/utils/permissionHandler.ts index 615da77a..cad1b18e 100644 --- a/cli/src/opencode/utils/permissionHandler.ts +++ b/cli/src/opencode/utils/permissionHandler.ts @@ -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 {}, @@ -116,6 +133,35 @@ export class OpencodePermissionHandler extends BasePermissionHandler { + 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 diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index fef60a16..3d3b15b9 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -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. +`); diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 16122c7d..46fe6622 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -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', { diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 51dc3712..3f276cf3 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -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)) { diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 6e038a1d..1c40dd1c 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -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( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 128c3882..9d6ebd07 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -459,6 +459,13 @@ export class SyncEngine { throw new Error('Missing applied session config') } + const requestedKeys = Object.keys(config) as Array + for (const key of requestedKeys) { + if (!(key in applied)) { + throw new Error(`Session did not apply ${key}`) + } + } + this.sessionCache.applySessionConfig(sessionId, applied) } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 6f944320..459dcc78 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -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, diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index b081a6e1..f0555e9d 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -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) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 0c3008b1..2409018d 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -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 diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index fd18148d..3e32964f 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -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', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 64fbc174..acb8c697 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -88,7 +88,12 @@ function normalizeCodexTokenUsage(value: unknown, data?: Record ?? 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 diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index bd51bc9c..12c8ff68 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -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( diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 557a967c..08547d06 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -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' diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts index 85789aaa..38ad7924 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts @@ -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 = { 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] }))) diff --git a/web/src/components/NewSession/ReasoningEffortSelector.tsx b/web/src/components/NewSession/ReasoningEffortSelector.tsx index aea9f435..23d55f14 100644 --- a/web/src/components/NewSession/ReasoningEffortSelector.tsx +++ b/web/src/components/NewSession/ReasoningEffortSelector.tsx @@ -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) => ( diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index ebddd1d5..cf4b3a1e 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -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({ diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index e504b261..aa31e5d5 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -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( @@ -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 }[] = [ diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 586a3495..d53de999 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -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 } diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 12fbc375..d0814d78 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -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) }, diff --git a/web/src/hooks/queries/useOpencodeModels.test.ts b/web/src/hooks/queries/useOpencodeModels.test.ts new file mode 100644 index 00000000..95101906 --- /dev/null +++ b/web/src/hooks/queries/useOpencodeModels.test.ts @@ -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) + }) +}) diff --git a/web/src/hooks/queries/useOpencodeModels.ts b/web/src/hooks/queries/useOpencodeModels.ts index 6a790223..14bd09ce 100644 --- a/web/src/hooks/queries/useOpencodeModels.ts +++ b/web/src/hooks/queries/useOpencodeModels.ts @@ -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 {