fix(opencode): use ACP-reported reasoning effort options (#853)

* 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>

* fix(opencode): sync hub effort after coerced setConfigOption

When resolveThoughtLevelEffort falls back to a different supported value,
roll back session state after a successful ACP update so keepalive and the
web UI do not keep advertising the rejected effort.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-06-10 13:57:03 +08:00
committed by GitHub
co-authored by Cursor
parent 3473a88d67
commit cad58cfa0b
18 changed files with 486 additions and 35 deletions
+6
View File
@@ -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<RpcListOpencodeReasoningEffortOptionsResponse> {
return await this.sessionRpc(sessionId, RPC_METHODS.ListOpencodeReasoningEffortOptions, {}) as RpcListOpencodeReasoningEffortOptionsResponse
}
private async sessionRpc(
sessionId: string,
method: string,
+6
View File
@@ -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<RpcListOpencodeModelsResponse> {
return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd)
}
async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise<RpcListOpencodeReasoningEffortOptionsResponse> {
return await this.rpcGateway.listOpencodeReasoningEffortOptionsForSession(sessionId)
}
}
+36
View File
@@ -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' }
+30
View File
@@ -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) {