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
@@ -0,0 +1,182 @@
import { describe, expect, it, vi } from 'vitest';
import type { ApiSessionClient } from '@/api/apiSession';
import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types';
import { OpencodePermissionHandler } from './permissionHandler';
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}));
type FakeAgentState = {
requests: Record<string, unknown>;
completedRequests: Record<string, unknown>;
};
function createHarness(getPermissionMode: () => 'default' | 'plan' | 'yolo' = () => 'default') {
let agentState: FakeAgentState = {
requests: {},
completedRequests: {}
};
const rpcHandlers = new Map<string, (params: unknown) => Promise<unknown> | unknown>();
let permissionHandler: ((request: PermissionRequest) => void) | null = null;
const respondCalls: Array<{
sessionId: string;
request: PermissionRequest;
response: PermissionResponse;
}> = [];
const session = {
rpcHandlerManager: {
registerHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown) {
rpcHandlers.set(method, handler);
}
},
updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) {
agentState = handler(agentState);
}
} as unknown as ApiSessionClient;
const backend: AgentBackend = {
async initialize() {},
async newSession() {
return 'agent-session';
},
async prompt() {},
async cancelPrompt() {},
async respondToPermission(sessionId, request, response) {
respondCalls.push({ sessionId, request, response });
},
onPermissionRequest(handler) {
permissionHandler = handler;
},
async disconnect() {}
};
new OpencodePermissionHandler(session, backend, getPermissionMode);
return {
rpcHandlers,
respondCalls,
getAgentState: () => agentState,
emitPermissionRequest(request: PermissionRequest) {
if (!permissionHandler) {
throw new Error('Permission handler was not registered');
}
permissionHandler(request);
}
};
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function buildRequest(overrides?: Partial<PermissionRequest>): PermissionRequest {
return {
id: 'perm-1',
sessionId: 'session-1',
toolCallId: 'perm-1',
title: 'Write',
rawInput: { path: 'file.ts' },
options: [
{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
},
{
optionId: 'reject-once',
name: 'Reject once',
kind: 'reject_once'
}
],
...overrides
};
}
describe('OpencodePermissionHandler plan mode', () => {
it('denies non-auto-approved tool requests instead of queueing them', async () => {
const harness = createHarness(() => 'plan');
harness.emitPermissionRequest(buildRequest());
await flushAsyncWork();
expect(harness.respondCalls).toEqual([
{
sessionId: 'session-1',
request: expect.objectContaining({ id: 'perm-1', title: 'Write' }),
response: { outcome: 'selected', optionId: 'reject-once' }
}
]);
expect(harness.getAgentState().requests).toEqual({});
expect(harness.getAgentState().completedRequests).toMatchObject({
'perm-1': {
tool: 'Write',
status: 'denied',
decision: 'denied',
reason: 'Plan mode blocks tool execution'
}
});
});
it('cancels plan-mode requests when OpenCode offers no reject option', async () => {
const harness = createHarness(() => 'plan');
harness.emitPermissionRequest(buildRequest({
id: 'perm-no-reject',
options: [
{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
}
]
}));
await flushAsyncWork();
expect(harness.respondCalls).toEqual([
{
sessionId: 'session-1',
request: expect.objectContaining({ id: 'perm-no-reject' }),
response: { outcome: 'cancelled' }
}
]);
expect(harness.getAgentState().completedRequests).toMatchObject({
'perm-no-reject': {
status: 'canceled',
decision: 'abort'
}
});
});
it('still auto-approves hapi title updates in plan mode', async () => {
const harness = createHarness(() => 'plan');
harness.emitPermissionRequest(buildRequest({
id: 'perm-title',
toolCallId: 'perm-title',
title: 'hapi_change_title',
rawInput: { title: 'Planning' }
}));
await flushAsyncWork();
expect(harness.respondCalls).toEqual([
{
sessionId: 'session-1',
request: expect.objectContaining({ id: 'perm-title', title: 'hapi_change_title' }),
response: { outcome: 'selected', optionId: 'allow-once' }
}
]);
expect(harness.getAgentState().requests).toEqual({});
expect(harness.getAgentState().completedRequests).toMatchObject({
'perm-title': {
tool: 'hapi_change_title',
status: 'approved',
decision: 'approved'
}
});
});
});
+47 -1
View File
@@ -24,16 +24,28 @@ function deriveToolInput(request: PermissionRequest): unknown {
return request.rawOutput;
}
function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null {
function pickOptionId(
request: PermissionRequest,
preferredKinds: string[],
options: { fallbackToFirst?: boolean } = {}
): string | null {
for (const kind of preferredKinds) {
const match = request.options.find((option) => option.kind === kind);
if (match) {
return match.optionId;
}
}
if (options.fallbackToFirst === false) {
return null;
}
return request.options.length > 0 ? request.options[0].optionId : null;
}
function mapPlanModeDenialToOutcome(request: PermissionRequest): PermissionResponse {
const optionId = pickOptionId(request, ['reject_once', 'reject_always'], { fallbackToFirst: false });
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse {
if (decision === 'abort') {
return { outcome: 'cancelled' };
@@ -80,6 +92,11 @@ export class OpencodePermissionHandler extends BasePermissionHandler<PermissionR
return;
}
if (mode === 'plan') {
void this.denyForPlanMode(request, toolName, toolInput);
return;
}
this.pendingBackendRequests.set(request.id, request);
this.addPendingRequest(request.id, toolName, toolInput, {
resolve: () => {},
@@ -116,6 +133,35 @@ export class OpencodePermissionHandler extends BasePermissionHandler<PermissionR
logger.debug(`[Opencode] Auto-approved ${toolName} (${request.id}) mode=${decision}`);
}
private async denyForPlanMode(
request: PermissionRequest,
toolName: string,
toolInput: unknown
): Promise<void> {
const outcome = mapPlanModeDenialToOutcome(request);
await this.backend.respondToPermission(request.sessionId, request, outcome);
const timestamp = Date.now();
const status = outcome.outcome === 'selected' ? 'denied' : 'canceled';
this.client.updateAgentState((currentState) => ({
...currentState,
completedRequests: {
...currentState.completedRequests,
[request.id]: {
tool: toolName,
arguments: toolInput,
createdAt: timestamp,
completedAt: timestamp,
status,
reason: 'Plan mode blocks tool execution',
decision: status === 'denied' ? 'denied' : 'abort'
}
}
}));
logger.debug(`[Opencode] Denied ${toolName} (${request.id}) in plan mode`);
}
protected async handlePermissionResponse(
response: PermissionResponseMessage,
pending: PendingPermissionRequest<void>
+7
View File
@@ -19,3 +19,10 @@ export const TITLE_INSTRUCTION = trimIdent(`
* The system prompt to inject for OpenCode sessions.
*/
export const opencodeSystemPrompt = TITLE_INSTRUCTION;
/**
* Instruction prepended to OpenCode prompts while HAPI plan mode is active.
*/
export const PLAN_MODE_INSTRUCTION = trimIdent(`
You are in plan mode. Do not execute tools or make changes. Analyze the request, ask clarifying questions if needed, and respond with a concise implementation plan only.
`);