mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user