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
@@ -155,6 +155,45 @@ describe('AcpSdkBackend', () => {
});
});
it('captures model metadata from configOptions when models block is missing', async () => {
const backend = new AcpSdkBackend({ command: 'opencode' });
const backendInternal = backend as unknown as {
transport: { sendRequest: (method: string, params: unknown) => Promise<unknown>; close: () => Promise<void> } | null;
};
backendInternal.transport = {
sendRequest: async (method) => {
if (method === 'session/new') {
return {
sessionId: 'opencode-session-config-options',
configOptions: [
{
id: 'model',
category: 'model',
currentValue: 'opencode/big-pickle',
options: [
{ value: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
{ value: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
]
}
]
};
}
return null;
},
close: async () => {}
};
const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });
expect(backend.getSessionModelsMetadata(sessionId)).toEqual({
availableModels: [
{ modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
{ modelId: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
],
currentModelId: 'opencode/big-pickle'
});
});
it('returns undefined session metadata when session/new omits models', async () => {
const backend = new AcpSdkBackend({ command: 'gemini' });
const backendInternal = backend as unknown as {
@@ -213,6 +252,67 @@ describe('AcpSdkBackend', () => {
});
});
it('captures and sets OpenCode thought-level config option', async () => {
const backend = new AcpSdkBackend({ command: 'opencode' });
const calls: Array<{ method: string; params: unknown }> = [];
const backendInternal = backend as unknown as {
transport: { sendRequest: (method: string, params: unknown) => Promise<unknown>; close: () => Promise<void> } | null;
};
backendInternal.transport = {
sendRequest: async (method, params) => {
calls.push({ method, params });
if (method === 'session/new') {
return {
sessionId: 's1',
configOptions: [{
id: 'effort',
name: 'Effort',
category: 'thought_level',
type: 'select',
currentValue: 'low',
options: [
{ value: 'low', name: 'Low' },
{ value: 'high', name: 'High' }
]
}]
};
}
if (method === 'session/set_config_option') {
return {
configOptions: [{
id: 'effort',
category: 'thought_level',
currentValue: 'high',
options: [{ value: 'high', name: 'High' }]
}]
};
}
return null;
},
close: async () => {}
};
await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });
expect(backend.getThoughtLevelConfigOption('s1')).toMatchObject({
id: 'effort',
currentValue: 'low',
options: [{ value: 'low', name: 'Low' }, { value: 'high', name: 'High' }]
});
await backend.setConfigOption('s1', 'effort', 'high');
expect(calls).toContainEqual({
method: 'session/set_config_option',
params: { sessionId: 's1', configId: 'effort', value: 'high' }
});
expect(backend.getThoughtLevelConfigOption('s1')).toMatchObject({
id: 'effort',
currentValue: 'high'
});
});
it('emits turn_complete after trailing tool updates from the same turn', async () => {
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
@@ -284,4 +384,65 @@ describe('AcpSdkBackend', () => {
'turn_complete'
]);
});
it('combines OpenCode usage_update and prompt usage into a usage message', async () => {
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
backendStatics.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 1;
backendStatics.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 50;
const backend = new AcpSdkBackend({ command: 'opencode' });
const backendInternal = backend as unknown as {
transport: {
sendRequest: (...args: unknown[]) => Promise<unknown>;
close: () => Promise<void>;
} | null;
handleSessionUpdate: (params: unknown) => void;
};
const messages: AgentMessage[] = [];
backendInternal.transport = {
sendRequest: async () => {
setTimeout(() => {
backendInternal.handleSessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: 'usage_update',
used: 13_879,
size: 65_536,
}
});
}, 0);
await sleep(5);
return {
stopReason: 'end_turn',
usage: {
totalTokens: 13_892,
inputTokens: 8_119,
outputTokens: 2,
thoughtTokens: 11,
cachedReadTokens: 5_760
}
};
},
close: async () => {}
};
await backend.prompt('session-1', [{ type: 'text', text: 'hello' }], (message) => {
messages.push(message);
});
expect(messages).toContainEqual({
type: 'usage',
inputTokens: 8_119,
outputTokens: 2,
cacheReadTokens: 5_760,
thoughtTokens: 11,
totalTokens: 13_892,
contextTokens: 13_879,
contextWindow: 65_536
});
});
});
+162 -6
View File
@@ -3,6 +3,7 @@ import type { AgentBackend, AgentMessage, AgentSessionConfig, PermissionRequest,
import { asString, isObject } from '@hapi/protocol';
import { AcpStdioTransport, type AcpStderrError } from './AcpStdioTransport';
import { AcpMessageHandler } from './AcpMessageHandler';
import { ACP_SESSION_UPDATE_TYPES } from './constants';
import { logger } from '@/ui/logger';
import { withRetry } from '@/utils/time';
import packageJson from '../../../../package.json';
@@ -11,6 +12,19 @@ type PendingPermission = {
resolve: (result: { outcome: { outcome: string; optionId?: string } }) => void;
};
type AcpPromptUsage = {
inputTokens: number;
outputTokens: number;
totalTokens?: number;
thoughtTokens?: number;
cacheReadTokens?: number;
};
type AcpUsageUpdate = {
contextTokens: number | undefined;
contextWindow: number | undefined;
};
export type AcpModelDescriptor = {
modelId: string;
name?: string;
@@ -21,17 +35,26 @@ export type AcpSessionModelsMetadata = {
currentModelId: string | null;
};
export type AcpConfigOptionDescriptor = {
id: string;
category?: string;
currentValue?: string;
options: Array<{ value: string; name?: string }>;
};
export class AcpSdkBackend implements AgentBackend {
private transport: AcpStdioTransport | null = null;
private permissionHandler: ((request: PermissionRequest) => void) | null = null;
private stderrErrorHandler: ((error: AcpStderrError) => void) | null = null;
private readonly pendingPermissions = new Map<string, PendingPermission>();
private readonly sessionModelsMetadata = new Map<string, AcpSessionModelsMetadata>();
private readonly sessionConfigOptions = new Map<string, AcpConfigOptionDescriptor[]>();
private messageHandler: AcpMessageHandler | null = null;
private activeSessionId: string | null = null;
private isProcessingMessage = false;
private responseCompleteResolvers: Array<() => void> = [];
private lastSessionUpdateAt = 0;
private latestUsageUpdate: AcpUsageUpdate | null = null;
/** Retry configuration for ACP initialization */
private static readonly INIT_RETRY_OPTIONS = {
@@ -120,7 +143,7 @@ export class AcpSdkBackend implements AgentBackend {
}
this.activeSessionId = sessionId;
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionMetadata(sessionId, response);
return sessionId;
}
@@ -146,7 +169,7 @@ export class AcpSdkBackend implements AgentBackend {
const loadedSessionId = isObject(response) ? asString(response.sessionId) : null;
const sessionId = loadedSessionId ?? config.sessionId;
this.activeSessionId = sessionId;
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionMetadata(sessionId, response);
return sessionId;
}
@@ -183,10 +206,29 @@ export class AcpSdkBackend implements AgentBackend {
} else {
// For other flavors (e.g. Gemini), if the response carries metadata,
// capture it. Missing fields are silently ignored.
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionMetadata(sessionId, response);
}
}
async setConfigOption(
sessionId: string,
configId: string,
value: string
): Promise<void> {
if (!this.transport) {
throw new Error('ACP transport not initialized');
}
await this.waitForResponseComplete();
const response = await this.transport.sendRequest('session/set_config_option', {
sessionId,
configId,
value
});
this.captureSessionMetadata(sessionId, response);
}
/**
* Returns the per-session models metadata captured from session/new (or
* session/load, or session/set_model). Returns undefined if the agent did
@@ -196,6 +238,10 @@ export class AcpSdkBackend implements AgentBackend {
return this.sessionModelsMetadata.get(sessionId);
}
getThoughtLevelConfigOption(sessionId: string): AcpConfigOptionDescriptor | undefined {
return this.sessionConfigOptions.get(sessionId)?.find((option) => option.category === 'thought_level');
}
async prompt(
sessionId: string,
content: PromptContent[],
@@ -219,7 +265,9 @@ export class AcpSdkBackend implements AgentBackend {
this.messageHandler = new AcpMessageHandler(onUpdate);
this.isProcessingMessage = true;
this.lastSessionUpdateAt = Date.now();
this.latestUsageUpdate = null;
let stopReason: string | null = null;
let promptUsage: AcpPromptUsage | null = null;
try {
// No timeout for prompt requests - they can run for extended periods
@@ -230,6 +278,7 @@ export class AcpSdkBackend implements AgentBackend {
}, { timeoutMs: Infinity });
stopReason = isObject(response) ? asString(response.stopReason) : null;
promptUsage = this.extractPromptUsage(response);
} finally {
await this.waitForSessionUpdateQuiet(
AcpSdkBackend.UPDATE_QUIET_PERIOD_MS,
@@ -237,6 +286,19 @@ export class AcpSdkBackend implements AgentBackend {
);
this.messageHandler?.drainBuffers();
try {
const latestUsageUpdate = this.readLatestUsageUpdate();
if (promptUsage) {
onUpdate({
type: 'usage',
inputTokens: promptUsage.inputTokens,
outputTokens: promptUsage.outputTokens,
totalTokens: promptUsage.totalTokens,
thoughtTokens: promptUsage.thoughtTokens,
cacheReadTokens: promptUsage.cacheReadTokens,
contextTokens: latestUsageUpdate ? latestUsageUpdate.contextTokens : undefined,
contextWindow: latestUsageUpdate ? latestUsageUpdate.contextWindow : undefined
});
}
if (stopReason) {
onUpdate({ type: 'turn_complete', stopReason });
}
@@ -336,9 +398,26 @@ export class AcpSdkBackend implements AgentBackend {
}
this.lastSessionUpdateAt = Date.now();
const update = params.update;
this.captureUsageUpdate(update);
this.messageHandler?.handleUpdate(update);
}
private captureUsageUpdate(update: unknown): void {
if (!isObject(update)) return;
if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.usageUpdate) return;
const contextTokens = this.asFiniteNumber(update.used);
const contextWindow = this.asFiniteNumber(update.size);
this.latestUsageUpdate = {
contextTokens: contextTokens ?? undefined,
contextWindow: contextWindow ?? undefined
};
}
private readLatestUsageUpdate(): AcpUsageUpdate | null {
return this.latestUsageUpdate;
}
private async waitForSessionUpdateQuiet(quietMs: number, timeoutMs: number): Promise<void> {
if (quietMs <= 0 || timeoutMs <= 0) {
return;
@@ -434,6 +513,64 @@ export class AcpSdkBackend implements AgentBackend {
});
}
private extractPromptUsage(response: unknown): AcpPromptUsage | null {
if (!isObject(response) || !isObject(response.usage)) return null;
const usage = response.usage;
const inputTokens = this.asFiniteNumber(usage.inputTokens ?? usage.input_tokens);
const outputTokens = this.asFiniteNumber(usage.outputTokens ?? usage.output_tokens);
if (inputTokens === null || outputTokens === null) return null;
return {
inputTokens,
outputTokens,
totalTokens: this.asFiniteNumber(usage.totalTokens ?? usage.total_tokens) ?? undefined,
thoughtTokens: this.asFiniteNumber(usage.thoughtTokens ?? usage.thought_tokens) ?? undefined,
cacheReadTokens: this.asFiniteNumber(
usage.cachedReadTokens
?? usage.cached_read_tokens
?? usage.cachedInputTokens
?? usage.cached_input_tokens
) ?? undefined
};
}
private asFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
private captureSessionMetadata(sessionId: string, response: unknown): void {
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionConfigOptions(sessionId, response);
}
private captureSessionConfigOptions(sessionId: string, response: unknown): void {
if (!isObject(response) || !Array.isArray(response.configOptions)) return;
const options = response.configOptions
.filter((entry): entry is Record<string, unknown> => isObject(entry))
.map((entry): AcpConfigOptionDescriptor | null => {
const id = asString(entry.id);
if (!id) return null;
const rawOptions = Array.isArray(entry.options) ? entry.options : [];
return {
id,
category: asString(entry.category) ?? undefined,
currentValue: asString(entry.currentValue) ?? undefined,
options: rawOptions
.filter((option): option is Record<string, unknown> => isObject(option))
.map((option) => ({
value: asString(option.value) ?? '',
name: asString(option.name) ?? undefined
}))
.filter((option) => option.value.length > 0)
};
})
.filter((entry): entry is AcpConfigOptionDescriptor => entry !== null);
this.sessionConfigOptions.set(sessionId, options);
}
/**
* Extract `availableModels` and `currentModelId` from an ACP response and
* store them keyed by sessionId. Both top-level and nested-under-`models`
@@ -442,6 +579,24 @@ export class AcpSdkBackend implements AgentBackend {
* expose model metadata (e.g. current Gemini ACP build) simply leave the
* cache untouched.
*/
private extractModelConfigOption(response: Record<string, unknown>): {
currentValue: string | null;
options: unknown[];
} | null {
if (!Array.isArray(response.configOptions)) return null;
for (const entry of response.configOptions) {
if (!isObject(entry)) continue;
if (asString(entry.category) !== 'model') continue;
return {
currentValue: asString(entry.currentValue),
options: Array.isArray(entry.options) ? entry.options : []
};
}
return null;
}
private captureSessionModelsMetadata(sessionId: string, response: unknown): void {
if (!isObject(response)) return;
@@ -451,16 +606,17 @@ export class AcpSdkBackend implements AgentBackend {
const nestedList = nested?.availableModels;
const nestedCurrent = nested?.currentModelId;
const configModelOption = this.extractModelConfigOption(response);
const rawModels = Array.isArray(directList)
? directList
: Array.isArray(nestedList)
? nestedList
: null;
: configModelOption?.options ?? null;
const rawCurrent = typeof directCurrent === 'string'
? directCurrent
: typeof nestedCurrent === 'string'
? nestedCurrent
: null;
: configModelOption?.currentValue ?? null;
if (rawModels === null && rawCurrent === null) {
return;
@@ -470,7 +626,7 @@ export class AcpSdkBackend implements AgentBackend {
if (Array.isArray(rawModels)) {
for (const entry of rawModels) {
if (!isObject(entry)) continue;
const modelId = asString(entry.modelId);
const modelId = asString(entry.modelId) ?? asString(entry.value);
if (!modelId) continue;
const name = asString(entry.name) ?? undefined;
availableModels.push(name ? { modelId, name } : { modelId });
+2 -1
View File
@@ -3,5 +3,6 @@ export const ACP_SESSION_UPDATE_TYPES = {
agentThoughtChunk: 'agent_thought_chunk',
toolCall: 'tool_call',
toolCallUpdate: 'tool_call_update',
plan: 'plan'
plan: 'plan',
usageUpdate: 'usage_update'
} as const;
+28
View File
@@ -49,4 +49,32 @@ describe('convertAgentMessage', () => {
id: 'reasoning-stream-1'
});
});
it('converts usage messages into token_count payloads', () => {
const converted = convertAgentMessage({
type: 'usage',
inputTokens: 8_119,
outputTokens: 2,
cacheReadTokens: 5_760,
thoughtTokens: 11,
totalTokens: 13_892,
contextTokens: 13_879,
contextWindow: 65_536
});
expect(converted).toEqual({
type: 'token_count',
info: {
total: {
inputTokens: 8119,
outputTokens: 2,
cachedInputTokens: 5760,
thoughtTokens: 11,
totalTokens: 13892
},
contextTokens: 13879,
modelContextWindow: 65536
}
});
});
});
+29
View File
@@ -4,6 +4,20 @@ import type { AgentMessage, PlanItem } from './types';
export type CodexMessage =
| { type: 'message'; message: string }
| { type: 'reasoning'; message: string; id: string }
| {
type: 'token_count';
info: {
total: {
inputTokens: number;
outputTokens: number;
totalTokens?: number;
thoughtTokens?: number;
cachedInputTokens?: number;
};
contextTokens?: number;
modelContextWindow?: number;
};
}
| {
type: 'tool-call';
name: string;
@@ -29,6 +43,21 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null
// the wire-level CodexMessage uses `message` to match the
// existing reasoning format emitted by the Codex path.
return { type: 'reasoning', message: message.text, id: message.id ?? randomUUID() };
case 'usage':
return {
type: 'token_count',
info: {
total: {
inputTokens: message.inputTokens,
outputTokens: message.outputTokens,
totalTokens: message.totalTokens,
thoughtTokens: message.thoughtTokens,
cachedInputTokens: message.cacheReadTokens
},
contextTokens: message.contextTokens,
modelContextWindow: message.contextWindow
}
};
case 'tool_call':
return {
type: 'tool-call',
+23
View File
@@ -23,6 +23,10 @@ describe('sessionConfigRpc', () => {
expect(() => resolveSessionConfigPermissionMode('bypassPermissions', 'gemini')).toThrow('Invalid permission mode')
})
it('accepts OpenCode plan permission mode', () => {
expect(resolveSessionConfigPermissionMode('plan', 'opencode')).toBe('plan')
})
it('accepts null model for agents that support model config', () => {
expect(resolveNullableSessionModel(null)).toBeNull()
})
@@ -68,6 +72,25 @@ describe('sessionConfigRpc', () => {
expect(onApply).toHaveBeenCalledWith({})
})
it('applies nullable model reasoning effort when supported', async () => {
const harness = createRpcHarness()
const onApply = vi.fn()
registerSessionConfigRpc({
rpcHandlerManager: harness.rpcHandlerManager,
flavor: 'opencode',
modelReasoningEffortMode: 'nullable',
onApply
})
const result = await harness.getHandler()({ modelReasoningEffort: 'high' }) as { applied: Record<string, unknown> }
expect(result.applied.modelReasoningEffort).toBe('high')
expect(onApply).toHaveBeenCalledWith({ modelReasoningEffort: 'high' })
})
it('rejects model config for agents configured to reject model changes', async () => {
const harness = createRpcHarness()
+15 -1
View File
@@ -7,12 +7,14 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
type SessionConfigState<TPermissionMode extends PermissionMode = PermissionMode> = {
permissionMode?: TPermissionMode
model?: string | null
modelReasoningEffort?: string | null
}
type RegisterSessionConfigRpcOptions<TPermissionMode extends PermissionMode = PermissionMode> = {
rpcHandlerManager: RpcHandlerManager
flavor: AgentFlavor
modelMode?: 'nullable' | 'ignore' | 'reject'
modelReasoningEffortMode?: 'nullable' | 'ignore' | 'reject'
appliedFallback?: () => Record<string, unknown>
onApply: (config: SessionConfigState<TPermissionMode>) => void
onAfterApply?: () => void
@@ -43,6 +45,7 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
rpcHandlerManager,
flavor,
modelMode = 'reject',
modelReasoningEffortMode = 'reject',
appliedFallback,
onApply,
onAfterApply
@@ -52,7 +55,7 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
throw new Error('Invalid session config payload')
}
const config = payload as { permissionMode?: unknown; model?: unknown }
const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown }
const applied: Record<string, unknown> = {}
const next: SessionConfigState<TPermissionMode> = {}
@@ -71,6 +74,17 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
}
}
if (config.modelReasoningEffort !== undefined) {
if (modelReasoningEffortMode === 'reject') {
throw new Error('Invalid model reasoning effort')
}
if (modelReasoningEffortMode === 'nullable') {
next.modelReasoningEffort = resolveNullableSessionModel(config.modelReasoningEffort)
applied.modelReasoningEffort = next.modelReasoningEffort
}
}
onApply(next)
onAfterApply?.()
+19
View File
@@ -33,6 +33,16 @@ export type AgentMessage =
| { type: 'reasoning'; text: string; id?: string; live?: boolean }
| { type: 'tool_call'; id: string; name: string; input: unknown; status: 'pending' | 'in_progress' | 'completed' | 'failed' }
| { type: 'tool_result'; id: string; output: unknown; status: 'completed' | 'failed' }
| {
type: 'usage';
inputTokens: number;
outputTokens: number;
totalTokens?: number;
thoughtTokens?: number;
cacheReadTokens?: number;
contextTokens?: number;
contextWindow?: number;
}
| { type: 'plan'; items: PlanItem[] }
| { type: 'turn_complete'; stopReason: string }
| { type: 'error'; message: string };
@@ -68,11 +78,20 @@ export type AgentSessionModelsMetadata = {
currentModelId: string | null;
};
export type AgentSessionConfigOptionDescriptor = {
id: string;
category?: string;
currentValue?: string;
options: Array<{ value: string; name?: string }>;
};
export interface AgentBackend {
initialize(): Promise<void>;
newSession(config: AgentSessionConfig): Promise<string>;
setModel?(sessionId: string, modelId: string, opts?: { flavor?: AgentFlavor }): Promise<void>;
setConfigOption?(sessionId: string, configId: string, value: string): Promise<void>;
getSessionModelsMetadata?(sessionId: string): AgentSessionModelsMetadata | undefined;
getThoughtLevelConfigOption?(sessionId: string): AgentSessionConfigOptionDescriptor | undefined;
prompt(sessionId: string, content: PromptContent[], onUpdate: (msg: AgentMessage) => void): Promise<void>;
cancelPrompt(sessionId: string): Promise<void>;
respondToPermission(sessionId: string, request: PermissionRequest, response: PermissionResponse): Promise<void>;