feat(opencode): add plan mode, reasoning effort, and status telemetry (#688)

* feat(opencode): support plan mode

* feat(opencode): support reasoning effort

* feat(opencode): surface context usage in web

Bridge OpenCode ACP usage updates into the existing token-count pipeline so the web status bar can show live context and cache information without a separate UI path.

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

* fix(opencode): restrict plan mode to remote, rollback reasoning effort on failure

- Block local OpenCode plan startup (tools not enforced in local path)
- Allow remote OpenCode plan only (ACP permission handler denies tools)
- Guard web /permission-mode endpoint for local OpenCode plan sessions
- Rollback session reasoning effort when OpenCode rejects set_config_option
- Wire rollback callback through opencodeLoop to runOpencode closure
- Add tests: local plan rejected, remote plan allowed, web guard, effort rollback

* fix(web): auto-retry OpenCode models query to populate model selector without refresh

- Retry early failures (RPC may still be registering on new sessions)
- Poll briefly until availableModels is non-empty
- Stop polling once model options are discovered
- Add tests for retry/poll/stop policy

* fix(opencode): cap model discovery polling

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-05-27 11:17:24 +08:00
committed by GitHub
co-authored by Cursor
parent d5a67b717c
commit 6f2bb7d32b
43 changed files with 1263 additions and 68 deletions
+4 -4
View File
@@ -106,11 +106,11 @@ export function useSessionActions(
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
if (agentFlavor !== 'codex') {
throw new Error('Model reasoning effort is only supported for Codex sessions')
if (agentFlavor !== 'codex' && agentFlavor !== 'opencode') {
throw new Error('Model reasoning effort is only supported for Codex and OpenCode sessions')
}
if (!codexCollaborationModeSupported) {
throw new Error('Model reasoning effort is only supported for remote Codex sessions')
if (agentFlavor === 'codex' && !codexCollaborationModeSupported) {
throw new Error('Model reasoning effort is only supported for remote sessions')
}
await api.setModelReasoningEffort(sessionId, modelReasoningEffort)
},
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { getOpencodeModelsRefetchInterval, shouldRetryOpencodeModelsQuery } from './useOpencodeModels'
describe('useOpencodeModels retry policy', () => {
it('retries early failures while the session model RPC may still be registering', () => {
expect(shouldRetryOpencodeModelsQuery(0)).toBe(true)
expect(shouldRetryOpencodeModelsQuery(2)).toBe(true)
expect(shouldRetryOpencodeModelsQuery(3)).toBe(false)
})
it('polls briefly until OpenCode session models are discovered', () => {
expect(getOpencodeModelsRefetchInterval(true, undefined, 0)).toBe(1000)
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] }, 1)).toBe(1000)
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' }, 2)).toBe(1000)
})
it('stops polling once model options are available or the query is disabled', () => {
expect(getOpencodeModelsRefetchInterval(true, {
success: true,
availableModels: [{ modelId: 'provider/model', name: 'Provider Model' }],
currentModelId: 'provider/model'
}, 1)).toBe(false)
expect(getOpencodeModelsRefetchInterval(false, undefined, 0)).toBe(false)
})
it('stops polling after the discovery poll cap', () => {
expect(getOpencodeModelsRefetchInterval(true, undefined, 10)).toBe(false)
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] }, 10)).toBe(false)
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' }, 10)).toBe(false)
})
})
+30 -1
View File
@@ -1,8 +1,32 @@
import { useQuery } from '@tanstack/react-query'
import type { OpencodeModelsResponse } from '@hapi/protocol/apiTypes'
import type { ApiClient } from '@/api/client'
import type { OpencodeModelSummary } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
export function shouldRetryOpencodeModelsQuery(failureCount: number): boolean {
return failureCount < 3
}
const MAX_OPENCODE_MODEL_DISCOVERY_POLLS = 10
export function getOpencodeModelsRefetchInterval(
enabled: boolean,
data: OpencodeModelsResponse | undefined,
pollCount: number
): 1000 | false {
if (!enabled || pollCount >= MAX_OPENCODE_MODEL_DISCOVERY_POLLS) {
return false
}
if (!data) {
return 1000
}
if (data.success === false) {
return 1000
}
return (data.availableModels?.length ?? 0) > 0 ? false : 1000
}
export function useOpencodeModels(args: {
api: ApiClient | null
sessionId?: string | null
@@ -31,7 +55,12 @@ export function useOpencodeModels(args: {
},
enabled,
staleTime: 30_000,
retry: false,
retry: (failureCount) => shouldRetryOpencodeModelsQuery(failureCount),
refetchInterval: (query) => getOpencodeModelsRefetchInterval(
enabled,
query.state.data as OpencodeModelsResponse | undefined,
query.state.dataUpdateCount + query.state.errorUpdateCount
),
})
return {