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
+40
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockOpencodeSession = vi.hoisted(() => ({
setModel: vi.fn(),
setPermissionMode: vi.fn(),
setModelReasoningEffort: vi.fn(),
pushKeepAlive: vi.fn(),
thinking: false,
stopKeepAlive: vi.fn()
@@ -89,6 +90,7 @@ describe('runOpencode set-session-config handler', () => {
harness.opencodeLoopError = null;
mockOpencodeSession.setModel.mockReset();
mockOpencodeSession.setPermissionMode.mockReset();
mockOpencodeSession.setModelReasoningEffort.mockReset();
mockOpencodeSession.pushKeepAlive.mockReset();
harness.session.onUserMessage.mockReset();
harness.session.rpcHandlerManager.registerHandler.mockReset();
@@ -109,6 +111,20 @@ describe('runOpencode set-session-config handler', () => {
return configHandler![1] as (payload: unknown) => Promise<unknown>;
}
it('rejects plan mode for local OpenCode startup', async () => {
await expect(runOpencode({ permissionMode: 'plan' })).rejects.toThrow(
'OpenCode plan mode is only supported in remote mode'
);
expect(harness.opencodeLoopArgs).toEqual([]);
});
it('allows plan mode for remote OpenCode startup', async () => {
await runOpencode({ permissionMode: 'plan', startingMode: 'remote' });
expect(harness.opencodeLoopArgs[0]?.permissionMode).toBe('plan');
expect(harness.opencodeLoopArgs[0]?.startingMode).toBe('remote');
});
it('applies model change via set-session-config RPC', async () => {
await runOpencode({});
@@ -178,6 +194,30 @@ describe('runOpencode set-session-config handler', () => {
expect(applied.permissionMode).toBe('yolo');
});
it('accepts plan mode via set-session-config RPC', async () => {
await runOpencode({});
const handler = getConfigHandler();
const result = await handler({ permissionMode: 'plan' }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
expect(applied.permissionMode).toBe('plan');
expect(mockOpencodeSession.setPermissionMode).toHaveBeenLastCalledWith('plan');
});
it('accepts model reasoning effort via set-session-config RPC', async () => {
await runOpencode({});
const handler = getConfigHandler();
const result = await handler({ modelReasoningEffort: 'high' }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
expect(applied.modelReasoningEffort).toBe('high');
expect(mockOpencodeSession.setModelReasoningEffort).toHaveBeenLastCalledWith('high');
});
it('passes initial model from opts through to the loop', async () => {
await runOpencode({ model: 'ollama/exaone:4.5-33b-q8' });