diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index 3a1aeae7..173fcbf7 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -255,7 +255,48 @@ describe('opencodeRemoteLauncher inline model switch', () => { expect(harness.promptCount).toBe(2); }); + it('rejects unsupported reasoning effort values before calling setConfigOption', async () => { + harness.thoughtLevelOption = { + id: 'effort', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ] + }; + const { session, setModelReasoningEffort } = createSessionStub([ + { message: 'first', mode: createModeWithEffort(undefined, 'high') } + ]); + await opencodeRemoteLauncher(session as never); + + expect(harness.setConfigOptionArgs).toEqual([]); + expect(setModelReasoningEffort).toHaveBeenCalledWith('low'); + expect(harness.promptCount).toBe(1); + }); + + it('syncs hub effort state after coercing an unsupported request to a different supported value', async () => { + harness.thoughtLevelOption = { + id: 'effort', + currentValue: 'high', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ] + }; + const { session, setModelReasoningEffort, pushKeepAlive } = createSessionStub([ + { message: 'first', mode: createModeWithEffort(undefined, 'max') } + ]); + + await opencodeRemoteLauncher(session as never); + + expect(harness.setConfigOptionArgs).toEqual([ + { sessionId: 'acp-session-1', configId: 'effort', value: 'low' } + ]); + expect(setModelReasoningEffort).toHaveBeenCalledWith('low'); + expect(pushKeepAlive).toHaveBeenCalledTimes(1); + expect(harness.promptCount).toBe(1); + }); it('resets to the backend launch-time default model when the queued mode.model is null', async () => { // Seed the backend with a launch-time default model so the launcher @@ -414,6 +455,48 @@ describe('opencodeRemoteLauncher inline model switch', () => { }); }); + it('registers a listOpencodeReasoningEffortOptions RPC handler that returns ACP options', async () => { + harness.thoughtLevelOption = { + id: 'effort', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ] + }; + const { session, rpcHandlers } = createSessionStub([ + { message: 'first', mode: createMode() } + ]); + await opencodeRemoteLauncher(session as never); + + const handler = rpcHandlers.get('listOpencodeReasoningEffortOptions'); + expect(handler).toBeDefined(); + const result = await handler!(undefined) as Record; + expect(result).toEqual({ + success: true, + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ], + currentValue: 'low' + }); + }); + + it('listOpencodeReasoningEffortOptions handler returns unavailable when backend has no thought level option', async () => { + const { session, rpcHandlers } = createSessionStub([ + { message: 'first', mode: createMode() } + ]); + await opencodeRemoteLauncher(session as never); + + const handler = rpcHandlers.get('listOpencodeReasoningEffortOptions'); + expect(handler).toBeDefined(); + const result = await handler!(undefined) as Record; + expect(result).toEqual({ + success: false, + error: 'OpenCode reasoning effort options are not available' + }); + }); + it('serializes setModel after the previous prompt resolves', async () => { const { session } = createSessionStub([ { message: 'first', mode: createMode('ollama/a') }, diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 6eeb8a78..b911fbca 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -11,6 +11,7 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { createOpencodeBackend } from './utils/opencodeBackend'; import { OpencodePermissionHandler } from './utils/permissionHandler'; import { PLAN_MODE_INSTRUCTION, TITLE_INSTRUCTION } from './utils/systemPrompt'; +import { resolveThoughtLevelEffort } from './thoughtLevelEffort'; type OpencodeRemoteLauncherOptions = { onReasoningEffortRollback?: (effort: string | null) => void; @@ -123,6 +124,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { }; }); + session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeReasoningEffortOptions, async () => { + const effortOption = backend.getThoughtLevelConfigOption?.(acpSessionId); + if (!effortOption) { + return { success: false, error: 'OpenCode reasoning effort options are not available' }; + } + return { + success: true, + options: effortOption.options, + currentValue: effortOption.currentValue ?? null + }; + }); + this.permissionHandler = new OpencodePermissionHandler( session.client, backend, @@ -206,29 +219,46 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { 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)'}.` - }); + const resolvedEffort = resolveThoughtLevelEffort( + requestedEffort, + thoughtLevelOption, + this.currentBackendEffort ?? this.defaultBackendEffort + ); + if (!resolvedEffort || resolvedEffort === this.currentBackendEffort) { + if (requestedEffort !== resolvedEffort) { + logger.warn( + `[opencode-remote] Unsupported reasoning effort "${requestedEffort}"; continuing with ${resolvedEffort ?? this.currentBackendEffort ?? '(default)'}` + ); + this.rollbackReasoningEffort(batch, resolvedEffort ?? this.currentBackendEffort); + } + } else { + logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${resolvedEffort}`); + try { + await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, resolvedEffort); + this.currentBackendEffort = resolvedEffort; + this.setEffortSupported = true; + if (requestedEffort !== resolvedEffort) { + this.rollbackReasoningEffort(batch, resolvedEffort); + } + } 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 ${resolvedEffort}. Continuing with ${this.currentBackendEffort ?? '(default)'}.` + }); + } + this.rollbackReasoningEffort(batch, this.currentBackendEffort); } - this.rollbackReasoningEffort(batch, this.currentBackendEffort); } } } diff --git a/cli/src/opencode/thoughtLevelEffort.test.ts b/cli/src/opencode/thoughtLevelEffort.test.ts new file mode 100644 index 00000000..032b43fe --- /dev/null +++ b/cli/src/opencode/thoughtLevelEffort.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { resolveThoughtLevelEffort } from './thoughtLevelEffort'; + +const thoughtLevelOption = { + id: 'effort', + category: 'thought_level', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ] +}; + +describe('resolveThoughtLevelEffort', () => { + it('returns the requested value when it is supported', () => { + expect(resolveThoughtLevelEffort('medium', thoughtLevelOption, 'low')).toBe('medium'); + }); + + it('falls back to the current backend effort when the request is unsupported', () => { + expect(resolveThoughtLevelEffort('high', thoughtLevelOption, 'low')).toBe('low'); + }); + + it('falls back to the ACP current value when the backend effort is also unsupported', () => { + expect(resolveThoughtLevelEffort('high', thoughtLevelOption, 'max')).toBe('low'); + }); + + it('falls back to the first supported option when nothing else matches', () => { + const option = { + ...thoughtLevelOption, + currentValue: 'high' + }; + expect(resolveThoughtLevelEffort('max', option, null)).toBe('low'); + }); +}); diff --git a/cli/src/opencode/thoughtLevelEffort.ts b/cli/src/opencode/thoughtLevelEffort.ts new file mode 100644 index 00000000..e972df46 --- /dev/null +++ b/cli/src/opencode/thoughtLevelEffort.ts @@ -0,0 +1,20 @@ +import type { AgentSessionConfigOptionDescriptor } from '@/agent/types'; + +export function resolveThoughtLevelEffort( + requested: string, + thoughtLevelOption: AgentSessionConfigOptionDescriptor, + fallback: string | null +): string | null { + const supported = new Set(thoughtLevelOption.options.map((option) => option.value)); + if (supported.has(requested)) { + return requested; + } + if (fallback && supported.has(fallback)) { + return fallback; + } + const current = thoughtLevelOption.currentValue; + if (current && supported.has(current)) { + return current; + } + return thoughtLevelOption.options[0]?.value ?? null; +} diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 0f371887..8e038940 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -13,6 +13,7 @@ import type { ListDirectoryResponse, OpencodeModelsResponse, OpencodeModelSummary, + OpencodeReasoningEffortResponse, PathExistsResponse, SlashCommandsResponse, UploadFileResponse @@ -37,6 +38,7 @@ export type RpcCursorModel = CursorModelSummary export type RpcListCursorModelsResponse = CursorModelsResponse export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse +export type RpcListOpencodeReasoningEffortOptionsResponse = OpencodeReasoningEffortResponse export class RpcGateway { constructor( @@ -258,6 +260,10 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListOpencodeModelsForCwd, { cwd }) as RpcListOpencodeModelsResponse } + async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { + return await this.sessionRpc(sessionId, RPC_METHODS.ListOpencodeReasoningEffortOptions, {}) as RpcListOpencodeReasoningEffortOptionsResponse + } + private async sessionRpc( sessionId: string, method: string, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a6157821..08661b02 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -29,6 +29,7 @@ import { type RpcListCodexModelsResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, + type RpcListOpencodeReasoningEffortOptionsResponse, type RpcCursorModel, type RpcOpencodeModel, type RpcPathExistsResponse, @@ -49,6 +50,7 @@ export type { RpcListCodexModelsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, + RpcListOpencodeReasoningEffortOptionsResponse, RpcCursorModel, RpcOpencodeModel, RpcPathExistsResponse, @@ -1088,4 +1090,8 @@ export class SyncEngine { async listOpencodeModelsForCwd(machineId: string, cwd: string): Promise { return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd) } + + async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { + return await this.rpcGateway.listOpencodeReasoningEffortOptionsForSession(sessionId) + } } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 604ef867..6ec0be83 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -80,6 +80,14 @@ function createApp(session: Session, opts?: { ], currentModelId: 'ollama/exaone:4.5-33b-q8' }) + const listOpencodeReasoningEffortOptionsForSession = async () => ({ + success: true, + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ], + currentValue: 'low' + }) const listCursorModelsForSession = async () => ({ success: true, availableModels: [ @@ -103,6 +111,7 @@ function createApp(session: Session, opts?: { listCodexModelsForSession, listCursorModelsForSession, listOpencodeModelsForSession, + listOpencodeReasoningEffortOptionsForSession, resumeSession, reopenSession, getSessionExport: opts?.getSessionExport ?? (() => ({ @@ -543,6 +552,33 @@ describe('sessions routes', () => { }) }) + it('returns OpenCode reasoning effort options for active OpenCode sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' } + }) + const { app } = createApp(session) + + const response = await app.request('/api/sessions/session-1/opencode-reasoning-effort-options') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ], + currentValue: 'low' + }) + }) + + it('rejects opencode-reasoning-effort-options for non-OpenCode sessions', async () => { + const { app } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/opencode-reasoning-effort-options') + + expect(response.status).toBe(400) + }) + it('returns OpenCode models for active OpenCode sessions', async () => { const session = createSession({ metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' } diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index b1b5c003..195c54e5 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -688,6 +688,36 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/opencode-reasoning-effort-options', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'opencode') { + return c.json({ + success: false, + error: 'OpenCode reasoning effort options are only available for OpenCode sessions' + }, 400) + } + + try { + const result = await engine.listOpencodeReasoningEffortOptionsForSession(sessionResult.sessionId) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list OpenCode reasoning effort options' + }, 500) + } + }) + app.get('/sessions/:id/cursor-models', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index e9ea6a1f..b8a223ac 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -320,6 +320,18 @@ export type OpencodeModelsResponse = { export type ListOpencodeModelsResponse = OpencodeModelsResponse +export type OpencodeReasoningEffortOption = { + value: string + name?: string +} + +export type OpencodeReasoningEffortResponse = { + success: boolean + options?: OpencodeReasoningEffortOption[] + currentValue?: string | null + error?: string +} + export type CursorModelSummary = OpencodeModelSummary export type CursorModelsResponse = OpencodeModelsResponse diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 2d77c1a1..0ac67fac 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -28,7 +28,8 @@ export const RPC_METHODS = { ListCodexModels: 'listCodexModels', ListCursorModels: 'listCursorModels', ListOpencodeModels: 'listOpencodeModels', - ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd' + ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', + ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions' } as const export type RpcMethod = typeof RPC_METHODS[keyof typeof RPC_METHODS] diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2a7bc912..b5b89568 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -33,6 +33,7 @@ import type { MachineListDirectoryResponse, MachinePathsExistsResponse, OpencodeModelsResponse, + OpencodeReasoningEffortResponse, ReopenSessionResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' @@ -564,6 +565,12 @@ export class ApiClient { ) } + async getSessionOpencodeReasoningEffortOptions(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/opencode-reasoning-effort-options` + ) + } + async getSessionCursorModels(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/cursor-models` diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 96ae5753..d0bf4634 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -87,6 +87,7 @@ export function HappyComposer(props: { controlledByUser?: boolean agentFlavor?: string | null availableModelOptions?: Array<{ value: string | null; label: string }> + availableModelReasoningEffortOptions?: Array<{ value: string; name?: string }> /** Cursor: selected base model key (not wire id). */ selectedModelBase?: string | null /** Cursor: selected variant sku/wire for highlight when session stores an ACP wire id. */ @@ -147,6 +148,7 @@ export function HappyComposer(props: { controlledByUser = false, agentFlavor, availableModelOptions, + availableModelReasoningEffortOptions, selectedModelBase, selectedModelVariant, modelEffortOptions, @@ -383,9 +385,13 @@ export function HappyComposer(props: { ) const codexReasoningEffortOptions = useMemo( () => agentFlavor === 'codex' || agentFlavor === 'opencode' - ? getCodexComposerReasoningEffortOptions(modelReasoningEffort, agentFlavor) + ? getCodexComposerReasoningEffortOptions( + modelReasoningEffort, + agentFlavor, + agentFlavor === 'opencode' ? availableModelReasoningEffortOptions : undefined + ) : [], - [agentFlavor, modelReasoningEffort] + [agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions] ) const claudeEffortOptions = useMemo( () => getClaudeComposerEffortOptions(effort), diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts index 88a3272e..1b531f34 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import { getCodexComposerReasoningEffortOptions } from './codexReasoningEffortOptions' describe('getCodexComposerReasoningEffortOptions', () => { - it('includes the default option and preset values', () => { - expect(getCodexComposerReasoningEffortOptions(null)).toEqual([ + it('includes the default option and preset values for Codex', () => { + expect(getCodexComposerReasoningEffortOptions(null, 'codex')).toEqual([ { value: null, label: 'Default' }, { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, @@ -12,8 +12,8 @@ describe('getCodexComposerReasoningEffortOptions', () => { ]) }) - it('preserves non-preset current values', () => { - expect(getCodexComposerReasoningEffortOptions('minimal')).toEqual([ + it('preserves non-preset current values for Codex', () => { + expect(getCodexComposerReasoningEffortOptions('minimal', 'codex')).toEqual([ { value: null, label: 'Default' }, { value: 'minimal', label: 'Minimal' }, { value: 'low', label: 'Low' }, @@ -22,4 +22,32 @@ describe('getCodexComposerReasoningEffortOptions', () => { { value: 'xhigh', label: 'XHigh' } ]) }) + + it('returns no options for OpenCode until dynamic options are available', () => { + expect(getCodexComposerReasoningEffortOptions(null, 'opencode')).toEqual([]) + expect(getCodexComposerReasoningEffortOptions(null, 'opencode', [])).toEqual([]) + }) + + it('builds OpenCode options from ACP-reported values', () => { + expect(getCodexComposerReasoningEffortOptions('low', 'opencode', [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ])).toEqual([ + { value: null, label: 'Default' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' } + ]) + }) + + it('preserves unsupported current OpenCode values in the dropdown', () => { + expect(getCodexComposerReasoningEffortOptions('high', 'opencode', [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' } + ])).toEqual([ + { value: null, label: 'Default' }, + { value: 'high', label: 'High' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' } + ]) + }) }) diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts index 38ad7924..b2dfc9d0 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts @@ -3,8 +3,12 @@ export type CodexComposerReasoningEffortOption = { label: string } +export type ComposerReasoningEffortSourceOption = { + value: string + name?: string +} + const CODEX_REASONING_EFFORT_PRESETS = ['low', 'medium', 'high', 'xhigh'] as const -const OPENCODE_REASONING_EFFORT_PRESETS = ['low', 'medium', 'high', 'max'] as const const CODEX_REASONING_EFFORT_LABELS: Record = { low: 'Low', medium: 'Medium', @@ -27,19 +31,51 @@ function formatCodexReasoningEffortLabel(effort: string): string { ?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}` } +function buildOpencodeComposerReasoningEffortOptions( + currentEffort: string | null, + dynamicOptions: ComposerReasoningEffortSourceOption[] +): CodexComposerReasoningEffortOption[] { + const optionValues = new Set(dynamicOptions.map((option) => option.value)) + const options: CodexComposerReasoningEffortOption[] = [ + { value: null, label: 'Default' } + ] + + if (currentEffort && !optionValues.has(currentEffort)) { + options.push({ + value: currentEffort, + label: formatCodexReasoningEffortLabel(currentEffort) + }) + } + + options.push(...dynamicOptions.map((option) => ({ + value: option.value, + label: option.name ?? formatCodexReasoningEffortLabel(option.value) + }))) + + return options +} + export function getCodexComposerReasoningEffortOptions( currentEffort?: string | null, - flavor?: string | null + flavor?: string | null, + dynamicOptions?: ComposerReasoningEffortSourceOption[] | null ): CodexComposerReasoningEffortOption[] { const normalizedCurrentEffort = normalizeCodexComposerReasoningEffort(currentEffort) - const presets = flavor === 'opencode' ? OPENCODE_REASONING_EFFORT_PRESETS : CODEX_REASONING_EFFORT_PRESETS + + if (flavor === 'opencode') { + if (!dynamicOptions || dynamicOptions.length === 0) { + return [] + } + return buildOpencodeComposerReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) + } + const options: CodexComposerReasoningEffortOption[] = [ { value: null, label: 'Default' } ] if ( normalizedCurrentEffort - && !(presets as readonly string[]).includes(normalizedCurrentEffort) + && !(CODEX_REASONING_EFFORT_PRESETS as readonly string[]).includes(normalizedCurrentEffort) ) { options.push({ value: normalizedCurrentEffort, @@ -47,7 +83,7 @@ export function getCodexComposerReasoningEffortOptions( }) } - options.push(...presets.map((effort) => ({ + options.push(...CODEX_REASONING_EFFORT_PRESETS.map((effort) => ({ value: effort, label: CODEX_REASONING_EFFORT_LABELS[effort] }))) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 3390ee7b..ce0a4adb 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -51,6 +51,7 @@ import { } from '@/lib/sessionChatCursorModel' import { buildCursorEffortPickerOptions, resolveCursorVariantOptions } from '@/lib/cursorModelOptions' import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels' +import { useOpencodeReasoningEffortOptions } from '@/hooks/queries/useOpencodeReasoningEffortOptions' import { useVoiceOptional } from '@/lib/voice-context' import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' import { isRemoteTerminalSupported } from '@/utils/terminalSupport' @@ -411,6 +412,11 @@ function SessionChatInner(props: SessionChatProps) { sessionId: props.session.id, enabled: agentFlavor === 'opencode' && props.session.active }) + const opencodeReasoningEffortState = useOpencodeReasoningEffortOptions({ + api: props.api, + sessionId: props.session.id, + enabled: agentFlavor === 'opencode' && props.session.active + }) const opencodeModelOptions = useMemo(() => { if (agentFlavor !== 'opencode') { return undefined @@ -1082,6 +1088,11 @@ function SessionChatInner(props: SessionChatProps) { ? opencodeModelOptions : undefined } + availableModelReasoningEffortOptions={ + agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0 + ? opencodeReasoningEffortState.options + : undefined + } active={props.session.active} allowSendWhenInactive thinking={props.session.thinking} @@ -1140,7 +1151,10 @@ function SessionChatInner(props: SessionChatProps) { : undefined } onModelReasoningEffortChange={ - (agentFlavor === 'codex' || agentFlavor === 'opencode') && props.session.active && !controlledByUser + (agentFlavor === 'codex' || agentFlavor === 'opencode') + && props.session.active + && !controlledByUser + && (agentFlavor !== 'opencode' || opencodeReasoningEffortState.options.length > 0) ? handleModelReasoningEffortChange : undefined } diff --git a/web/src/hooks/queries/useOpencodeReasoningEffortOptions.test.ts b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.test.ts new file mode 100644 index 00000000..aae7a7ca --- /dev/null +++ b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { getOpencodeReasoningEffortRefetchInterval, shouldRetryOpencodeReasoningEffortQuery } from './useOpencodeReasoningEffortOptions' + +describe('useOpencodeReasoningEffortOptions retry policy', () => { + it('retries transient failures up to three times', () => { + expect(shouldRetryOpencodeReasoningEffortQuery(0)).toBe(true) + expect(shouldRetryOpencodeReasoningEffortQuery(2)).toBe(true) + expect(shouldRetryOpencodeReasoningEffortQuery(3)).toBe(false) + }) + + it('polls until options are available', () => { + expect(getOpencodeReasoningEffortRefetchInterval(true, undefined, 0)).toBe(1000) + expect(getOpencodeReasoningEffortRefetchInterval(true, { success: false, error: 'not ready' }, 2)).toBe(1000) + expect(getOpencodeReasoningEffortRefetchInterval(true, { + success: true, + options: [{ value: 'low', name: 'Low' }] + }, 1)).toBe(false) + }) + + it('stops polling when disabled or after the max poll count', () => { + expect(getOpencodeReasoningEffortRefetchInterval(false, undefined, 0)).toBe(false) + expect(getOpencodeReasoningEffortRefetchInterval(true, undefined, 10)).toBe(false) + }) +}) diff --git a/web/src/hooks/queries/useOpencodeReasoningEffortOptions.ts b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.ts new file mode 100644 index 00000000..7f13c09c --- /dev/null +++ b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.ts @@ -0,0 +1,77 @@ +import { useQuery } from '@tanstack/react-query' +import type { OpencodeReasoningEffortResponse } from '@hapi/protocol/apiTypes' +import type { ApiClient } from '@/api/client' +import { queryKeys } from '@/lib/query-keys' + +export function shouldRetryOpencodeReasoningEffortQuery(failureCount: number): boolean { + return failureCount < 3 +} + +const MAX_OPENCODE_REASONING_EFFORT_DISCOVERY_POLLS = 10 + +export function getOpencodeReasoningEffortRefetchInterval( + enabled: boolean, + data: OpencodeReasoningEffortResponse | undefined, + pollCount: number +): 1000 | false { + if (!enabled || pollCount >= MAX_OPENCODE_REASONING_EFFORT_DISCOVERY_POLLS) { + return false + } + if (!data) { + return 1000 + } + if (data.success === false) { + return 1000 + } + return (data.options?.length ?? 0) > 0 ? false : 1000 +} + +export function useOpencodeReasoningEffortOptions(args: { + api: ApiClient | null + sessionId?: string | null + enabled?: boolean +}): { + options: Array<{ value: string; name?: string }> + currentValue: string | null + isLoading: boolean + error: string | null +} { + const { api, sessionId } = args + const enabled = Boolean(args.enabled && api && sessionId) + + const query = useQuery({ + queryKey: sessionId + ? queryKeys.sessionOpencodeReasoningEffortOptions(sessionId) + : ['session-opencode-reasoning-effort-options', 'unknown'] as const, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + if (!sessionId) { + throw new Error('OpenCode reasoning effort target unavailable') + } + return await api.getSessionOpencodeReasoningEffortOptions(sessionId) + }, + enabled, + staleTime: 30_000, + retry: (failureCount) => shouldRetryOpencodeReasoningEffortQuery(failureCount), + refetchInterval: (query) => getOpencodeReasoningEffortRefetchInterval( + enabled, + query.state.data as OpencodeReasoningEffortResponse | undefined, + query.state.dataUpdateCount + query.state.errorUpdateCount + ), + }) + + return { + options: query.data?.options ?? [], + currentValue: query.data?.currentValue ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load OpenCode reasoning effort options') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load OpenCode reasoning effort options' + : null, + } +} diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 89d0cc0b..a0664af7 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -19,6 +19,7 @@ export const queryKeys = { sessionCursorModels: (sessionId: string) => ['session-cursor-models', sessionId] as const, machineCursorModels: (machineId: string) => ['machine-cursor-models', machineId] as const, sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const, + sessionOpencodeReasoningEffortOptions: (sessionId: string) => ['session-opencode-reasoning-effort-options', sessionId] as const, machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const, skills: (sessionId: string) => ['skills', sessionId] as const, }