fix(opencode): expose model-specific reasoning effort options (#1716)

* fix(cli): discover opencode thought_level via set_config_option on model switch

* fix(cli): apply and refresh opencode model switches so thought_level stays discoverable

* fix(web): track opencode effort options across model switches

* feat(cli,hub,web): dynamic opencode effort options in new-session form

* test(cli): avoid platform-specific process event narrowing

* fix(opencode): address variant discovery review findings

* fix(opencode): synchronize effort options with model targets

* fix(opencode): roll back rejected model targets

* fix(cli): guard opencode variant probe workspace paths

* fix(web): clear stale opencode effort on model switch

* fix(cli): clear stale opencode effort metadata

* fix(web): reset stale effort options on model switch

* fix(web): reset opencode effort poll budget

* test(web): enforce opencode effort poll budget
This commit is contained in:
Junmo Kim
2026-09-09 09:31:30 +08:00
committed by GitHub
parent 905d89e03a
commit 7031d60eb4
33 changed files with 1729 additions and 55 deletions
+65 -1
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, rmSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
import { existsSync, mkdtempSync, rmSync, mkdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
const ioMock = vi.hoisted(() => vi.fn())
const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn())
const listOpencodeModelVariantsMock = vi.hoisted(() => vi.fn())
const listGrokModelsForCwdMock = vi.hoisted(() => vi.fn())
const listCopilotModelsForCwdMock = vi.hoisted(() => vi.fn())
const inspectCursorChatStoreMock = vi.hoisted(() => vi.fn())
@@ -21,6 +22,10 @@ vi.mock('../modules/common/opencodeModels', () => ({
listOpencodeModelsForCwd: listOpencodeModelsForCwdMock
}))
vi.mock('../modules/common/opencodeModelVariants', () => ({
listOpencodeModelVariants: listOpencodeModelVariantsMock
}))
vi.mock('../modules/common/grokModels', () => ({
listGrokModelsForCwd: listGrokModelsForCwdMock
}))
@@ -75,6 +80,15 @@ async function callListOpencodeModels(client: ApiMachineClient, machineId: strin
return JSON.parse(raw) as unknown
}
async function callListOpencodeModelVariants(client: ApiMachineClient, machineId: string, cwd: string): Promise<unknown> {
const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> } }).rpcHandlerManager
const raw = await manager.handleRequest({
method: `${machineId}:listOpencodeModelVariants`,
params: JSON.stringify({ cwd })
})
return JSON.parse(raw) as unknown
}
async function callListGrokModels(client: ApiMachineClient, machineId: string, cwd: string): Promise<unknown> {
const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> } }).rpcHandlerManager
const raw = await manager.handleRequest({
@@ -311,6 +325,56 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
})
})
describe('ApiMachineClient listOpencodeModelVariants handler', () => {
let workspaceRoot: string
beforeEach(() => {
ioMock.mockReset()
listOpencodeModelVariantsMock.mockReset()
workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-opencode-variants-ws-'))
})
afterEach(() => {
rmSync(workspaceRoot, { recursive: true, force: true })
})
it('rejects cwd outside workspace roots before running the variant probe', async () => {
const machine = makeMachine('opencode-variants-machine-1')
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-opencode-variants-outside-'))
try {
expect(await callListOpencodeModelVariants(client, machine.id, outsideCwd)).toEqual({
success: false,
error: 'Path is outside workspace roots'
})
expect(listOpencodeModelVariantsMock).not.toHaveBeenCalled()
} finally {
rmSync(outsideCwd, { recursive: true, force: true })
client.shutdown()
}
})
it('rejects a workspace symlink that escapes the configured roots', async () => {
const machine = makeMachine('opencode-variants-machine-2')
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-opencode-variants-symlink-outside-'))
const escape = join(workspaceRoot, 'escape')
symlinkSync(outsideCwd, escape, 'dir')
try {
expect(await callListOpencodeModelVariants(client, machine.id, escape)).toEqual({
success: false,
error: 'Path is outside workspace roots'
})
expect(listOpencodeModelVariantsMock).not.toHaveBeenCalled()
} finally {
rmSync(outsideCwd, { recursive: true, force: true })
client.shutdown()
}
})
})
describe('ApiMachineClient listCopilotModelsForCwd handler', () => {
let workspaceRoot: string
+21
View File
@@ -34,6 +34,10 @@ import {
type ListOpencodeModelsForCwdRequest,
type ListOpencodeModelsForCwdResponse
} from '../modules/common/opencodeModels'
import {
listOpencodeModelVariants,
type ListOpencodeModelVariantsResponse
} from '../modules/common/opencodeModelVariants'
import {
listGrokModelsForCwd,
type ListGrokModelsForCwdRequest,
@@ -256,6 +260,23 @@ export class ApiMachineClient {
}
)
this.rpcHandlerManager.registerHandler<{ cwd?: string | null }, ListOpencodeModelVariantsResponse>(
RPC_METHODS.ListOpencodeModelVariants,
async (params) => {
const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : ''
if (!rawCwd) {
return { success: false, error: 'cwd is required' }
}
const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd)
if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) {
return { success: false, error: 'Path is outside workspace roots' }
}
return await listOpencodeModelVariants({ cwd: resolvedCwd })
}
)
this.rpcHandlerManager.registerHandler<ListGrokModelsForCwdRequest, ListGrokModelsForCwdResponse>(
RPC_METHODS.ListGrokModelsForCwd,
async (params) => {