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
+66 -7
View File
@@ -6,11 +6,15 @@ import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'
import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase';
import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay';
import type { OpencodeSession } from './session';
import type { PermissionMode } from './types';
import type { OpencodeMode, PermissionMode } from './types';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
import { createOpencodeBackend } from './utils/opencodeBackend';
import { OpencodePermissionHandler } from './utils/permissionHandler';
import { TITLE_INSTRUCTION } from './utils/systemPrompt';
import { PLAN_MODE_INSTRUCTION, TITLE_INSTRUCTION } from './utils/systemPrompt';
type OpencodeRemoteLauncherOptions = {
onReasoningEffortRollback?: (effort: string | null) => void;
};
class OpencodeRemoteLauncher extends RemoteLauncherBase {
private readonly session: OpencodeSession;
@@ -21,9 +25,15 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
private displayPermissionMode: PermissionMode | null = null;
private instructionsSent = false;
private currentBackendModel: string | null = null;
private currentBackendEffort: string | null = null;
private defaultBackendEffort: string | null = null;
private setModelSupported: boolean | undefined = undefined;
private setEffortSupported: boolean | undefined = undefined;
constructor(session: OpencodeSession) {
constructor(
session: OpencodeSession,
private readonly options: OpencodeRemoteLauncherOptions = {}
) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
}
@@ -93,13 +103,16 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
// does not trigger a redundant setModel on the very first turn.
const initialMetadata = backend.getSessionModelsMetadata?.(acpSessionId);
this.currentBackendModel = initialMetadata?.currentModelId ?? null;
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
// Expose the cached models metadata via per-session RPC so the hub can
// forward it to the web UI's model selector without round-tripping ACP.
session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeModels, async () => {
const metadata = backend.getSessionModelsMetadata?.(acpSessionId);
if (!metadata) {
return { success: true, availableModels: [], currentModelId: null };
return { success: false, error: 'OpenCode model metadata is not available' };
}
return {
success: true,
@@ -175,13 +188,49 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
}
}
const requestedEffort = batch.mode.modelReasoningEffort ?? this.defaultBackendEffort;
if (requestedEffort && requestedEffort !== this.currentBackendEffort) {
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
if (!backend.setConfigOption || !thoughtLevelOption || this.setEffortSupported === false) {
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
} else {
logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${requestedEffort}`);
try {
await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, requestedEffort);
this.currentBackendEffort = requestedEffort;
this.setEffortSupported = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const methodNotFound = /method not found/i.test(message);
if (methodNotFound && this.setEffortSupported === undefined) {
this.setEffortSupported = false;
logger.warn('[opencode-remote] OpenCode build does not support session/set_config_option; inline effort switching disabled for this session');
session.sendSessionEvent({
type: 'message',
message: 'This OpenCode build does not support inline reasoning effort switching.'
});
} else {
logger.warn('[opencode-remote] Inline effort switch failed', error);
session.sendSessionEvent({
type: 'message',
message: `Failed to switch reasoning effort to ${requestedEffort}. Continuing with ${this.currentBackendEffort ?? '(default)'}.`
});
}
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
}
}
}
this.applyDisplayMode(batch.mode.permissionMode);
messageBuffer.addMessage(batch.message, 'user');
// Inject title instructions on first prompt
let messageText = batch.message;
if (batch.mode.permissionMode === 'plan') {
messageText = `${PLAN_MODE_INSTRUCTION}\n\n${messageText}`;
}
if (!this.instructionsSent) {
messageText = `${TITLE_INSTRUCTION}\n\n${batch.message}`;
messageText = `${TITLE_INSTRUCTION}\n\n${messageText}`;
this.instructionsSent = true;
}
@@ -232,6 +281,13 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
}
}
private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void {
batch.mode.modelReasoningEffort = effort;
this.session.setModelReasoningEffort(effort);
this.session.pushKeepAlive();
this.options.onReasoningEffortRollback?.(effort);
}
private handleAgentMessage(message: AgentMessage): void {
const converted = convertAgentMessage(message);
if (converted) {
@@ -254,6 +310,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
case 'tool_result':
this.messageBuffer.addMessage('Tool result received', 'result');
break;
case 'usage':
break;
case 'plan':
this.messageBuffer.addMessage('Plan updated', 'status');
break;
@@ -313,8 +371,9 @@ function toAcpMcpServers(config: Record<string, { command: string; args: string[
}
export async function opencodeRemoteLauncher(
session: OpencodeSession
session: OpencodeSession,
options: OpencodeRemoteLauncherOptions = {}
): Promise<'switch' | 'exit'> {
const launcher = new OpencodeRemoteLauncher(session);
const launcher = new OpencodeRemoteLauncher(session, options);
return launcher.launch();
}