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
+20 -4
View File
@@ -74,16 +74,32 @@ describe('listOpencodeModelsForCwd', () => {
expect(closeMock).toHaveBeenCalled()
})
it('returns empty availableModels when session/new omits the models block', async () => {
it('reads availableModels from configOptions when session/new omits the models block', async () => {
sendRequestMock
.mockResolvedValueOnce({ protocolVersion: 1 })
.mockResolvedValueOnce({ sessionId: 'sess-2' })
.mockResolvedValueOnce({
sessionId: 'sess-2',
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' }
]
}
]
})
const result = await listOpencodeModelsForCwd('/tmp/proj')
expect(result.success).toBe(true)
expect(result.availableModels).toEqual([])
expect(result.currentModelId).toBeNull()
expect(result.availableModels).toEqual([
{ modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
{ modelId: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
])
expect(result.currentModelId).toBe('opencode/big-pickle')
})
it('reads availableModels from top-level fields too (alternate response shape)', async () => {
+22 -3
View File
@@ -25,7 +25,7 @@ function normalizeAvailableModels(rawModels: unknown): OpencodeModelSummary[] {
const out: OpencodeModelSummary[] = [];
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;
out.push(name ? { modelId, name } : { modelId });
@@ -33,6 +33,24 @@ function normalizeAvailableModels(rawModels: unknown): OpencodeModelSummary[] {
return out;
}
function 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;
}
function extractModelsFromResponse(response: unknown): {
availableModels: OpencodeModelSummary[];
currentModelId: string | null;
@@ -47,16 +65,17 @@ function extractModelsFromResponse(response: unknown): {
const nestedList = nested?.availableModels;
const nestedCurrent = nested?.currentModelId;
const configModelOption = 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;
return {
availableModels: normalizeAvailableModels(rawModels),