Skip to content

Commit 8ab1cc9

Browse files
committed
fix(opencode): cap model discovery polling
1 parent f686a6c commit 8ab1cc9

8 files changed

Lines changed: 125 additions & 23 deletions

File tree

cli/src/agent/backends/acp/AcpSdkBackend.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,45 @@ describe('AcpSdkBackend', () => {
155155
});
156156
});
157157

158+
it('captures model metadata from configOptions when models block is missing', async () => {
159+
const backend = new AcpSdkBackend({ command: 'opencode' });
160+
const backendInternal = backend as unknown as {
161+
transport: { sendRequest: (method: string, params: unknown) => Promise<unknown>; close: () => Promise<void> } | null;
162+
};
163+
backendInternal.transport = {
164+
sendRequest: async (method) => {
165+
if (method === 'session/new') {
166+
return {
167+
sessionId: 'opencode-session-config-options',
168+
configOptions: [
169+
{
170+
id: 'model',
171+
category: 'model',
172+
currentValue: 'opencode/big-pickle',
173+
options: [
174+
{ value: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
175+
{ value: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
176+
]
177+
}
178+
]
179+
};
180+
}
181+
return null;
182+
},
183+
close: async () => {}
184+
};
185+
186+
const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });
187+
188+
expect(backend.getSessionModelsMetadata(sessionId)).toEqual({
189+
availableModels: [
190+
{ modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
191+
{ modelId: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
192+
],
193+
currentModelId: 'opencode/big-pickle'
194+
});
195+
});
196+
158197
it('returns undefined session metadata when session/new omits models', async () => {
159198
const backend = new AcpSdkBackend({ command: 'gemini' });
160199
const backendInternal = backend as unknown as {

cli/src/agent/backends/acp/AcpSdkBackend.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,24 @@ export class AcpSdkBackend implements AgentBackend {
579579
* expose model metadata (e.g. current Gemini ACP build) simply leave the
580580
* cache untouched.
581581
*/
582+
private extractModelConfigOption(response: Record<string, unknown>): {
583+
currentValue: string | null;
584+
options: unknown[];
585+
} | null {
586+
if (!Array.isArray(response.configOptions)) return null;
587+
588+
for (const entry of response.configOptions) {
589+
if (!isObject(entry)) continue;
590+
if (asString(entry.category) !== 'model') continue;
591+
return {
592+
currentValue: asString(entry.currentValue),
593+
options: Array.isArray(entry.options) ? entry.options : []
594+
};
595+
}
596+
597+
return null;
598+
}
599+
582600
private captureSessionModelsMetadata(sessionId: string, response: unknown): void {
583601
if (!isObject(response)) return;
584602

@@ -588,16 +606,17 @@ export class AcpSdkBackend implements AgentBackend {
588606
const nestedList = nested?.availableModels;
589607
const nestedCurrent = nested?.currentModelId;
590608

609+
const configModelOption = this.extractModelConfigOption(response);
591610
const rawModels = Array.isArray(directList)
592611
? directList
593612
: Array.isArray(nestedList)
594613
? nestedList
595-
: null;
614+
: configModelOption?.options ?? null;
596615
const rawCurrent = typeof directCurrent === 'string'
597616
? directCurrent
598617
: typeof nestedCurrent === 'string'
599618
? nestedCurrent
600-
: null;
619+
: configModelOption?.currentValue ?? null;
601620

602621
if (rawModels === null && rawCurrent === null) {
603622
return;
@@ -607,7 +626,7 @@ export class AcpSdkBackend implements AgentBackend {
607626
if (Array.isArray(rawModels)) {
608627
for (const entry of rawModels) {
609628
if (!isObject(entry)) continue;
610-
const modelId = asString(entry.modelId);
629+
const modelId = asString(entry.modelId) ?? asString(entry.value);
611630
if (!modelId) continue;
612631
const name = asString(entry.name) ?? undefined;
613632
availableModels.push(name ? { modelId, name } : { modelId });

cli/src/modules/common/opencodeModels.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,32 @@ describe('listOpencodeModelsForCwd', () => {
7474
expect(closeMock).toHaveBeenCalled()
7575
})
7676

77-
it('returns empty availableModels when session/new omits the models block', async () => {
77+
it('reads availableModels from configOptions when session/new omits the models block', async () => {
7878
sendRequestMock
7979
.mockResolvedValueOnce({ protocolVersion: 1 })
80-
.mockResolvedValueOnce({ sessionId: 'sess-2' })
80+
.mockResolvedValueOnce({
81+
sessionId: 'sess-2',
82+
configOptions: [
83+
{
84+
id: 'model',
85+
category: 'model',
86+
currentValue: 'opencode/big-pickle',
87+
options: [
88+
{ value: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
89+
{ value: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
90+
]
91+
}
92+
]
93+
})
8194

8295
const result = await listOpencodeModelsForCwd('/tmp/proj')
8396

8497
expect(result.success).toBe(true)
85-
expect(result.availableModels).toEqual([])
86-
expect(result.currentModelId).toBeNull()
98+
expect(result.availableModels).toEqual([
99+
{ modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
100+
{ modelId: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
101+
])
102+
expect(result.currentModelId).toBe('opencode/big-pickle')
87103
})
88104

89105
it('reads availableModels from top-level fields too (alternate response shape)', async () => {

cli/src/modules/common/opencodeModels.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,32 @@ function normalizeAvailableModels(rawModels: unknown): OpencodeModelSummary[] {
2525
const out: OpencodeModelSummary[] = [];
2626
for (const entry of rawModels) {
2727
if (!isObject(entry)) continue;
28-
const modelId = asString(entry.modelId);
28+
const modelId = asString(entry.modelId) ?? asString(entry.value);
2929
if (!modelId) continue;
3030
const name = asString(entry.name) ?? undefined;
3131
out.push(name ? { modelId, name } : { modelId });
3232
}
3333
return out;
3434
}
3535

36+
function extractModelConfigOption(response: Record<string, unknown>): {
37+
currentValue: string | null;
38+
options: unknown[];
39+
} | null {
40+
if (!Array.isArray(response.configOptions)) return null;
41+
42+
for (const entry of response.configOptions) {
43+
if (!isObject(entry)) continue;
44+
if (asString(entry.category) !== 'model') continue;
45+
return {
46+
currentValue: asString(entry.currentValue),
47+
options: Array.isArray(entry.options) ? entry.options : []
48+
};
49+
}
50+
51+
return null;
52+
}
53+
3654
function extractModelsFromResponse(response: unknown): {
3755
availableModels: OpencodeModelSummary[];
3856
currentModelId: string | null;
@@ -47,16 +65,17 @@ function extractModelsFromResponse(response: unknown): {
4765
const nestedList = nested?.availableModels;
4866
const nestedCurrent = nested?.currentModelId;
4967

68+
const configModelOption = extractModelConfigOption(response);
5069
const rawModels = Array.isArray(directList)
5170
? directList
5271
: Array.isArray(nestedList)
5372
? nestedList
54-
: null;
73+
: configModelOption?.options ?? null;
5574
const rawCurrent = typeof directCurrent === 'string'
5675
? directCurrent
5776
: typeof nestedCurrent === 'string'
5877
? nestedCurrent
59-
: null;
78+
: configModelOption?.currentValue ?? null;
6079

6180
return {
6281
availableModels: normalizeAvailableModels(rawModels),

cli/src/opencode/opencodeRemoteLauncher.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ describe('opencodeRemoteLauncher inline model switch', () => {
361361
});
362362
});
363363

364-
it('listOpencodeModels handler returns empty cache when backend has no metadata', async () => {
364+
it('listOpencodeModels handler returns unavailable when backend has no metadata', async () => {
365365
const { session, rpcHandlers } = createSessionStub([
366366
{ message: 'first', mode: createMode() }
367367
]);
@@ -371,9 +371,8 @@ describe('opencodeRemoteLauncher inline model switch', () => {
371371
expect(handler).toBeDefined();
372372
const result = await handler!(undefined) as Record<string, unknown>;
373373
expect(result).toEqual({
374-
success: true,
375-
availableModels: [],
376-
currentModelId: null
374+
success: false,
375+
error: 'OpenCode model metadata is not available'
377376
});
378377
});
379378

cli/src/opencode/opencodeRemoteLauncher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
112112
session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeModels, async () => {
113113
const metadata = backend.getSessionModelsMetadata?.(acpSessionId);
114114
if (!metadata) {
115-
return { success: true, availableModels: [], currentModelId: null };
115+
return { success: false, error: 'OpenCode model metadata is not available' };
116116
}
117117
return {
118118
success: true,

web/src/hooks/queries/useOpencodeModels.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,23 @@ describe('useOpencodeModels retry policy', () => {
99
})
1010

1111
it('polls briefly until OpenCode session models are discovered', () => {
12-
expect(getOpencodeModelsRefetchInterval(true, undefined)).toBe(1000)
13-
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] })).toBe(1000)
14-
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' })).toBe(1000)
12+
expect(getOpencodeModelsRefetchInterval(true, undefined, 0)).toBe(1000)
13+
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] }, 1)).toBe(1000)
14+
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' }, 2)).toBe(1000)
1515
})
1616

1717
it('stops polling once model options are available or the query is disabled', () => {
1818
expect(getOpencodeModelsRefetchInterval(true, {
1919
success: true,
2020
availableModels: [{ modelId: 'provider/model', name: 'Provider Model' }],
2121
currentModelId: 'provider/model'
22-
})).toBe(false)
23-
expect(getOpencodeModelsRefetchInterval(false, undefined)).toBe(false)
22+
}, 1)).toBe(false)
23+
expect(getOpencodeModelsRefetchInterval(false, undefined, 0)).toBe(false)
24+
})
25+
26+
it('stops polling after the discovery poll cap', () => {
27+
expect(getOpencodeModelsRefetchInterval(true, undefined, 10)).toBe(false)
28+
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] }, 10)).toBe(false)
29+
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' }, 10)).toBe(false)
2430
})
2531
})

web/src/hooks/queries/useOpencodeModels.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ export function shouldRetryOpencodeModelsQuery(failureCount: number): boolean {
88
return failureCount < 3
99
}
1010

11+
const MAX_OPENCODE_MODEL_DISCOVERY_POLLS = 10
12+
1113
export function getOpencodeModelsRefetchInterval(
1214
enabled: boolean,
13-
data?: OpencodeModelsResponse
15+
data: OpencodeModelsResponse | undefined,
16+
pollCount: number
1417
): 1000 | false {
15-
if (!enabled) {
18+
if (!enabled || pollCount >= MAX_OPENCODE_MODEL_DISCOVERY_POLLS) {
1619
return false
1720
}
1821
if (!data) {
@@ -55,7 +58,8 @@ export function useOpencodeModels(args: {
5558
retry: (failureCount) => shouldRetryOpencodeModelsQuery(failureCount),
5659
refetchInterval: (query) => getOpencodeModelsRefetchInterval(
5760
enabled,
58-
query.state.data as OpencodeModelsResponse | undefined
61+
query.state.data as OpencodeModelsResponse | undefined,
62+
query.state.dataUpdateCount + query.state.errorUpdateCount
5963
),
6064
})
6165

0 commit comments

Comments
 (0)