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
+36
View File
@@ -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',
+6 -1
View File
@@ -88,7 +88,12 @@ function normalizeCodexTokenUsage(value: unknown, data?: Record<string, unknown>
?? 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
@@ -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(
@@ -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'
@@ -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<string, string> = {
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]
})))
@@ -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) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
+2 -1
View File
@@ -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({
+2 -1
View File
@@ -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<TModel extends string>(
@@ -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 }[] = [
+2 -2
View File
@@ -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
}
+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 {