Skip to content

Commit 1131afb

Browse files
swear01cursoragent
andcommitted
fix(opencode): use ACP-reported reasoning effort options
Expose thought_level options from OpenCode ACP to the web UI via RPC/API instead of hardcoded presets, and validate effort values before setConfigOption. Fixes #852 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1f92a31 commit 1131afb

18 files changed

Lines changed: 460 additions & 35 deletions

cli/src/opencode/opencodeRemoteLauncher.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,25 @@ describe('opencodeRemoteLauncher inline model switch', () => {
255255
expect(harness.promptCount).toBe(2);
256256
});
257257

258+
it('rejects unsupported reasoning effort values before calling setConfigOption', async () => {
259+
harness.thoughtLevelOption = {
260+
id: 'effort',
261+
currentValue: 'low',
262+
options: [
263+
{ value: 'low', name: 'Low' },
264+
{ value: 'medium', name: 'Medium' }
265+
]
266+
};
267+
const { session, setModelReasoningEffort } = createSessionStub([
268+
{ message: 'first', mode: createModeWithEffort(undefined, 'high') }
269+
]);
258270

271+
await opencodeRemoteLauncher(session as never);
272+
273+
expect(harness.setConfigOptionArgs).toEqual([]);
274+
expect(setModelReasoningEffort).toHaveBeenCalledWith('low');
275+
expect(harness.promptCount).toBe(1);
276+
});
259277

260278
it('resets to the backend launch-time default model when the queued mode.model is null', async () => {
261279
// Seed the backend with a launch-time default model so the launcher
@@ -414,6 +432,48 @@ describe('opencodeRemoteLauncher inline model switch', () => {
414432
});
415433
});
416434

435+
it('registers a listOpencodeReasoningEffortOptions RPC handler that returns ACP options', async () => {
436+
harness.thoughtLevelOption = {
437+
id: 'effort',
438+
currentValue: 'low',
439+
options: [
440+
{ value: 'low', name: 'Low' },
441+
{ value: 'medium', name: 'Medium' }
442+
]
443+
};
444+
const { session, rpcHandlers } = createSessionStub([
445+
{ message: 'first', mode: createMode() }
446+
]);
447+
await opencodeRemoteLauncher(session as never);
448+
449+
const handler = rpcHandlers.get('listOpencodeReasoningEffortOptions');
450+
expect(handler).toBeDefined();
451+
const result = await handler!(undefined) as Record<string, unknown>;
452+
expect(result).toEqual({
453+
success: true,
454+
options: [
455+
{ value: 'low', name: 'Low' },
456+
{ value: 'medium', name: 'Medium' }
457+
],
458+
currentValue: 'low'
459+
});
460+
});
461+
462+
it('listOpencodeReasoningEffortOptions handler returns unavailable when backend has no thought level option', async () => {
463+
const { session, rpcHandlers } = createSessionStub([
464+
{ message: 'first', mode: createMode() }
465+
]);
466+
await opencodeRemoteLauncher(session as never);
467+
468+
const handler = rpcHandlers.get('listOpencodeReasoningEffortOptions');
469+
expect(handler).toBeDefined();
470+
const result = await handler!(undefined) as Record<string, unknown>;
471+
expect(result).toEqual({
472+
success: false,
473+
error: 'OpenCode reasoning effort options are not available'
474+
});
475+
});
476+
417477
it('serializes setModel after the previous prompt resolves', async () => {
418478
const { session } = createSessionStub([
419479
{ message: 'first', mode: createMode('ollama/a') },

cli/src/opencode/opencodeRemoteLauncher.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
1111
import { createOpencodeBackend } from './utils/opencodeBackend';
1212
import { OpencodePermissionHandler } from './utils/permissionHandler';
1313
import { PLAN_MODE_INSTRUCTION, TITLE_INSTRUCTION } from './utils/systemPrompt';
14+
import { resolveThoughtLevelEffort } from './thoughtLevelEffort';
1415

1516
type OpencodeRemoteLauncherOptions = {
1617
onReasoningEffortRollback?: (effort: string | null) => void;
@@ -123,6 +124,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
123124
};
124125
});
125126

127+
session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeReasoningEffortOptions, async () => {
128+
const effortOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
129+
if (!effortOption) {
130+
return { success: false, error: 'OpenCode reasoning effort options are not available' };
131+
}
132+
return {
133+
success: true,
134+
options: effortOption.options,
135+
currentValue: effortOption.currentValue ?? null
136+
};
137+
});
138+
126139
this.permissionHandler = new OpencodePermissionHandler(
127140
session.client,
128141
backend,
@@ -206,29 +219,43 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
206219
if (!backend.setConfigOption || !thoughtLevelOption || this.setEffortSupported === false) {
207220
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
208221
} else {
209-
logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${requestedEffort}`);
210-
try {
211-
await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, requestedEffort);
212-
this.currentBackendEffort = requestedEffort;
213-
this.setEffortSupported = true;
214-
} catch (error) {
215-
const message = error instanceof Error ? error.message : String(error);
216-
const methodNotFound = /method not found/i.test(message);
217-
if (methodNotFound && this.setEffortSupported === undefined) {
218-
this.setEffortSupported = false;
219-
logger.warn('[opencode-remote] OpenCode build does not support session/set_config_option; inline effort switching disabled for this session');
220-
session.sendSessionEvent({
221-
type: 'message',
222-
message: 'This OpenCode build does not support inline reasoning effort switching.'
223-
});
224-
} else {
225-
logger.warn('[opencode-remote] Inline effort switch failed', error);
226-
session.sendSessionEvent({
227-
type: 'message',
228-
message: `Failed to switch reasoning effort to ${requestedEffort}. Continuing with ${this.currentBackendEffort ?? '(default)'}.`
229-
});
222+
const resolvedEffort = resolveThoughtLevelEffort(
223+
requestedEffort,
224+
thoughtLevelOption,
225+
this.currentBackendEffort ?? this.defaultBackendEffort
226+
);
227+
if (!resolvedEffort || resolvedEffort === this.currentBackendEffort) {
228+
if (requestedEffort !== resolvedEffort) {
229+
logger.warn(
230+
`[opencode-remote] Unsupported reasoning effort "${requestedEffort}"; continuing with ${resolvedEffort ?? this.currentBackendEffort ?? '(default)'}`
231+
);
232+
this.rollbackReasoningEffort(batch, resolvedEffort ?? this.currentBackendEffort);
233+
}
234+
} else {
235+
logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${resolvedEffort}`);
236+
try {
237+
await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, resolvedEffort);
238+
this.currentBackendEffort = resolvedEffort;
239+
this.setEffortSupported = true;
240+
} catch (error) {
241+
const message = error instanceof Error ? error.message : String(error);
242+
const methodNotFound = /method not found/i.test(message);
243+
if (methodNotFound && this.setEffortSupported === undefined) {
244+
this.setEffortSupported = false;
245+
logger.warn('[opencode-remote] OpenCode build does not support session/set_config_option; inline effort switching disabled for this session');
246+
session.sendSessionEvent({
247+
type: 'message',
248+
message: 'This OpenCode build does not support inline reasoning effort switching.'
249+
});
250+
} else {
251+
logger.warn('[opencode-remote] Inline effort switch failed', error);
252+
session.sendSessionEvent({
253+
type: 'message',
254+
message: `Failed to switch reasoning effort to ${resolvedEffort}. Continuing with ${this.currentBackendEffort ?? '(default)'}.`
255+
});
256+
}
257+
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
230258
}
231-
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
232259
}
233260
}
234261
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { resolveThoughtLevelEffort } from './thoughtLevelEffort';
3+
4+
const thoughtLevelOption = {
5+
id: 'effort',
6+
category: 'thought_level',
7+
currentValue: 'low',
8+
options: [
9+
{ value: 'low', name: 'Low' },
10+
{ value: 'medium', name: 'Medium' }
11+
]
12+
};
13+
14+
describe('resolveThoughtLevelEffort', () => {
15+
it('returns the requested value when it is supported', () => {
16+
expect(resolveThoughtLevelEffort('medium', thoughtLevelOption, 'low')).toBe('medium');
17+
});
18+
19+
it('falls back to the current backend effort when the request is unsupported', () => {
20+
expect(resolveThoughtLevelEffort('high', thoughtLevelOption, 'low')).toBe('low');
21+
});
22+
23+
it('falls back to the ACP current value when the backend effort is also unsupported', () => {
24+
expect(resolveThoughtLevelEffort('high', thoughtLevelOption, 'max')).toBe('low');
25+
});
26+
27+
it('falls back to the first supported option when nothing else matches', () => {
28+
const option = {
29+
...thoughtLevelOption,
30+
currentValue: 'high'
31+
};
32+
expect(resolveThoughtLevelEffort('max', option, null)).toBe('low');
33+
});
34+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { AgentSessionConfigOptionDescriptor } from '@/agent/types';
2+
3+
export function resolveThoughtLevelEffort(
4+
requested: string,
5+
thoughtLevelOption: AgentSessionConfigOptionDescriptor,
6+
fallback: string | null
7+
): string | null {
8+
const supported = new Set(thoughtLevelOption.options.map((option) => option.value));
9+
if (supported.has(requested)) {
10+
return requested;
11+
}
12+
if (fallback && supported.has(fallback)) {
13+
return fallback;
14+
}
15+
const current = thoughtLevelOption.currentValue;
16+
if (current && supported.has(current)) {
17+
return current;
18+
}
19+
return thoughtLevelOption.options[0]?.value ?? null;
20+
}

hub/src/sync/rpcGateway.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ListDirectoryResponse,
1414
OpencodeModelsResponse,
1515
OpencodeModelSummary,
16+
OpencodeReasoningEffortResponse,
1617
PathExistsResponse,
1718
SlashCommandsResponse,
1819
UploadFileResponse
@@ -37,6 +38,7 @@ export type RpcCursorModel = CursorModelSummary
3738
export type RpcListCursorModelsResponse = CursorModelsResponse
3839
export type RpcOpencodeModel = OpencodeModelSummary
3940
export type RpcListOpencodeModelsResponse = OpencodeModelsResponse
41+
export type RpcListOpencodeReasoningEffortOptionsResponse = OpencodeReasoningEffortResponse
4042

4143
export class RpcGateway {
4244
constructor(
@@ -258,6 +260,10 @@ export class RpcGateway {
258260
return await this.machineRpc(machineId, RPC_METHODS.ListOpencodeModelsForCwd, { cwd }) as RpcListOpencodeModelsResponse
259261
}
260262

263+
async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise<RpcListOpencodeReasoningEffortOptionsResponse> {
264+
return await this.sessionRpc(sessionId, RPC_METHODS.ListOpencodeReasoningEffortOptions, {}) as RpcListOpencodeReasoningEffortOptionsResponse
265+
}
266+
261267
private async sessionRpc(
262268
sessionId: string,
263269
method: string,

hub/src/sync/syncEngine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
type RpcListCodexModelsResponse,
3030
type RpcListCursorModelsResponse,
3131
type RpcListOpencodeModelsResponse,
32+
type RpcListOpencodeReasoningEffortOptionsResponse,
3233
type RpcCursorModel,
3334
type RpcOpencodeModel,
3435
type RpcPathExistsResponse,
@@ -49,6 +50,7 @@ export type {
4950
RpcListCodexModelsResponse,
5051
RpcListCursorModelsResponse,
5152
RpcListOpencodeModelsResponse,
53+
RpcListOpencodeReasoningEffortOptionsResponse,
5254
RpcCursorModel,
5355
RpcOpencodeModel,
5456
RpcPathExistsResponse,
@@ -1088,4 +1090,8 @@ export class SyncEngine {
10881090
async listOpencodeModelsForCwd(machineId: string, cwd: string): Promise<RpcListOpencodeModelsResponse> {
10891091
return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd)
10901092
}
1093+
1094+
async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise<RpcListOpencodeReasoningEffortOptionsResponse> {
1095+
return await this.rpcGateway.listOpencodeReasoningEffortOptionsForSession(sessionId)
1096+
}
10911097
}

hub/src/web/routes/sessions.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ function createApp(session: Session, opts?: {
8080
],
8181
currentModelId: 'ollama/exaone:4.5-33b-q8'
8282
})
83+
const listOpencodeReasoningEffortOptionsForSession = async () => ({
84+
success: true,
85+
options: [
86+
{ value: 'low', name: 'Low' },
87+
{ value: 'medium', name: 'Medium' }
88+
],
89+
currentValue: 'low'
90+
})
8391
const listCursorModelsForSession = async () => ({
8492
success: true,
8593
availableModels: [
@@ -103,6 +111,7 @@ function createApp(session: Session, opts?: {
103111
listCodexModelsForSession,
104112
listCursorModelsForSession,
105113
listOpencodeModelsForSession,
114+
listOpencodeReasoningEffortOptionsForSession,
106115
resumeSession,
107116
reopenSession,
108117
getSessionExport: opts?.getSessionExport ?? (() => ({
@@ -543,6 +552,33 @@ describe('sessions routes', () => {
543552
})
544553
})
545554

555+
it('returns OpenCode reasoning effort options for active OpenCode sessions', async () => {
556+
const session = createSession({
557+
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }
558+
})
559+
const { app } = createApp(session)
560+
561+
const response = await app.request('/api/sessions/session-1/opencode-reasoning-effort-options')
562+
563+
expect(response.status).toBe(200)
564+
expect(await response.json()).toEqual({
565+
success: true,
566+
options: [
567+
{ value: 'low', name: 'Low' },
568+
{ value: 'medium', name: 'Medium' }
569+
],
570+
currentValue: 'low'
571+
})
572+
})
573+
574+
it('rejects opencode-reasoning-effort-options for non-OpenCode sessions', async () => {
575+
const { app } = createApp(createSession())
576+
577+
const response = await app.request('/api/sessions/session-1/opencode-reasoning-effort-options')
578+
579+
expect(response.status).toBe(400)
580+
})
581+
546582
it('returns OpenCode models for active OpenCode sessions', async () => {
547583
const session = createSession({
548584
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }

hub/src/web/routes/sessions.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,36 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
688688
}
689689
})
690690

691+
app.get('/sessions/:id/opencode-reasoning-effort-options', async (c) => {
692+
const engine = requireSyncEngine(c, getSyncEngine)
693+
if (engine instanceof Response) {
694+
return engine
695+
}
696+
697+
const sessionResult = requireSessionFromParam(c, engine, { requireActive: true })
698+
if (sessionResult instanceof Response) {
699+
return sessionResult
700+
}
701+
702+
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
703+
if (flavor !== 'opencode') {
704+
return c.json({
705+
success: false,
706+
error: 'OpenCode reasoning effort options are only available for OpenCode sessions'
707+
}, 400)
708+
}
709+
710+
try {
711+
const result = await engine.listOpencodeReasoningEffortOptionsForSession(sessionResult.sessionId)
712+
return c.json(result)
713+
} catch (error) {
714+
return c.json({
715+
success: false,
716+
error: error instanceof Error ? error.message : 'Failed to list OpenCode reasoning effort options'
717+
}, 500)
718+
}
719+
})
720+
691721
app.get('/sessions/:id/cursor-models', async (c) => {
692722
const engine = requireSyncEngine(c, getSyncEngine)
693723
if (engine instanceof Response) {

shared/src/apiTypes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,18 @@ export type OpencodeModelsResponse = {
320320

321321
export type ListOpencodeModelsResponse = OpencodeModelsResponse
322322

323+
export type OpencodeReasoningEffortOption = {
324+
value: string
325+
name?: string
326+
}
327+
328+
export type OpencodeReasoningEffortResponse = {
329+
success: boolean
330+
options?: OpencodeReasoningEffortOption[]
331+
currentValue?: string | null
332+
error?: string
333+
}
334+
323335
export type CursorModelSummary = OpencodeModelSummary
324336

325337
export type CursorModelsResponse = OpencodeModelsResponse

shared/src/rpcMethods.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export const RPC_METHODS = {
2828
ListCodexModels: 'listCodexModels',
2929
ListCursorModels: 'listCursorModels',
3030
ListOpencodeModels: 'listOpencodeModels',
31-
ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd'
31+
ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd',
32+
ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions'
3233
} as const
3334

3435
export type RpcMethod = typeof RPC_METHODS[keyof typeof RPC_METHODS]

0 commit comments

Comments
 (0)