diff --git a/apps/desktop/src/main/ipc-methods.ts b/apps/desktop/src/main/ipc-methods.ts
index fb40519b..4b49703c 100644
--- a/apps/desktop/src/main/ipc-methods.ts
+++ b/apps/desktop/src/main/ipc-methods.ts
@@ -40,6 +40,7 @@ export const IPC_METHODS = [
'attachments.read',
'providers.detect',
'providers.models',
+ 'providers.efforts',
'providers.plan',
'providers.install',
'providers.cancelInstall',
diff --git a/apps/desktop/src/main/rpc.ts b/apps/desktop/src/main/rpc.ts
index b3d51877..5c888103 100644
--- a/apps/desktop/src/main/rpc.ts
+++ b/apps/desktop/src/main/rpc.ts
@@ -207,6 +207,66 @@ async function probeAcpModels(
}
}
+/**
+ * Thought/reasoning levels for one kind resolved against a specific model.
+ * Effort is per-model on some agents (OpenCode's `effort` option appears
+ * only for reasoning-capable models), so the picker asks with the selected
+ * model rather than trusting the default-model catalog probe. Throws on
+ * transport failure so the caller keeps its catalog fallback.
+ */
+async function probeEffortsForModel(
+ kind: DriverKind,
+ modelId: string | null | undefined,
+): Promise<{ id: string; label: string; description?: string; current?: boolean }[]> {
+ const { detectDriver } = await import('@ari/providers/detector')
+ const environment = await resolveDetectionEnvironment()
+ const detection = await detectDriver(kind, environment)
+ if (!detection.binaryPath) return []
+ if (process.env['ARI_ACP'] === '0') return []
+ const launch = resolveAcpLaunch(kind, { cliBinaryPath: detection.binaryPath }, environment)
+ if (launch === null) return []
+ const { AcpConnection } = await import('@ari/providers/acp/connection')
+ const probeEnv = processEnvWithPath(environment.pathEnv)
+ const probe = probeLaunch(launch)
+ const connection = await AcpConnection.connect({
+ launch: probe,
+ cwd: homedir(),
+ initializeTimeoutMs: 20_000,
+ runtimeEnv: { ...probeEnv, ...probe.env },
+ })
+ try {
+ const created = await connection.newSession(homedir())
+ let configOptions = created.configOptions ?? []
+ if (typeof modelId === 'string' && modelId.length > 0) {
+ const modelOption = configOptions.find((o) => o.category === 'model' && o.type === 'select')
+ const offered = modelOption?.options?.some((v) => v.value === modelId) === true
+ if (modelOption?.id !== undefined && offered) {
+ const refreshed = await connection.setConfigOption(
+ created.sessionId as string,
+ modelOption.id,
+ modelId,
+ )
+ if (refreshed !== null && refreshed !== undefined && refreshed.length > 0) {
+ configOptions = refreshed
+ }
+ }
+ }
+ const catalog = mergeEffortCatalogs(
+ thoughtEffortsFromSession({ ...created, configOptions }),
+ thoughtEffortsFromMeta(connection.initialize._meta),
+ )
+ if (catalog.options.length > 0) setDynamicEfforts(kind, catalog)
+ return catalog.options.map((option) => ({
+ id: option.id,
+ label: option.label,
+ ...(option.description !== undefined ? { description: option.description } : {}),
+ ...(catalog.currentId === option.id ? { current: true as const } : {}),
+ }))
+ } finally {
+ await connection.shutdown()
+ }
+}
+
/**
* Every CLI kind probed by `providers.detect`, regardless of hydration state,
* so the providers grid is complete even while drivers are still registering.
@@ -1073,6 +1133,22 @@ export function registerRpc(contents: WebContents, options: RegisterRpcOptions =
})
})
+ // Model-aware effort lookup for the picker: a throwaway ACP session has
+ // the selected model applied, then reports that model's own thought
+ // levels. Empty on any failure — the caller keeps the catalog fallback.
+ r.register('providers.efforts', async (params) => {
+ try {
+ const efforts = await probeEffortsForModel(params.kind, params.modelId ?? null)
+ return { efforts }
+ } catch (error) {
+ log.debug('model-specific effort probe failed; keeping catalog fallback', {
+ kind: params.kind,
+ error: String(error),
+ })
+ return { efforts: [] }
+ }
+ })
+
r.register('window.minimize', () => {
BrowserWindow.fromWebContents(contents)?.minimize()
return { done: true }
diff --git a/apps/desktop/src/renderer/src/features/composer/ModelSelector.tsx b/apps/desktop/src/renderer/src/features/composer/ModelSelector.tsx
index 4f7a32a4..388255df 100644
--- a/apps/desktop/src/renderer/src/features/composer/ModelSelector.tsx
+++ b/apps/desktop/src/renderer/src/features/composer/ModelSelector.tsx
@@ -580,8 +580,8 @@ export function ModelSelector({
{!searching && fallbackLabel !== null && activeKind !== null ? (
- {driverLabel(activeKind)} has not reported its own models — this is{' '}
- {fallbackLabel}. It may not accept every entry.
+ {driverLabel(activeKind)} has not reported its own models — this is {fallbackLabel}.
+ It may not accept every entry.
{
await user.click(await screen.findByRole('button', { name: 'Approve plan' }))
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith('command.dispatch', {
- command: { type: 'input.respond', sessionId: 'sess_1', inputId: 'plan1', value: 'approved' },
+ command: {
+ type: 'input.respond',
+ sessionId: 'sess_1',
+ inputId: 'plan1',
+ value: 'approved',
+ },
})
})
expect(screen.queryByRole('complementary', { name: 'Plan review' })).not.toBeInTheDocument()
@@ -445,7 +450,12 @@ describe('SessionView edit and resend', () => {
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith('command.dispatch', {
- command: { type: 'turn.start', sessionId: 'sess_1', text: 'please retry the build, verbose', attachments: [] },
+ command: {
+ type: 'turn.start',
+ sessionId: 'sess_1',
+ text: 'please retry the build, verbose',
+ attachments: [],
+ },
})
})
})
@@ -483,10 +493,7 @@ describe('SessionView regenerate and retry', () => {
vi.clearAllMocks()
})
- function emitTurn(
- stopReason: 'completed' | 'error',
- errorMessage: string | null,
- ): void {
+ function emitTurn(stopReason: 'completed' | 'error', errorMessage: string | null): void {
emitSessionEvent({
seq: 1,
at: 1,
@@ -535,7 +542,12 @@ describe('SessionView regenerate and retry', () => {
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith('command.dispatch', {
- command: { type: 'turn.start', sessionId: 'sess_1', text: 'run the test suite', attachments: [] },
+ command: {
+ type: 'turn.start',
+ sessionId: 'sess_1',
+ text: 'run the test suite',
+ attachments: [],
+ },
})
})
})
@@ -598,7 +610,12 @@ describe('SessionView regenerate and retry', () => {
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith('command.dispatch', {
- command: { type: 'turn.start', sessionId: 'sess_1', text: 'run the test suite', attachments: [] },
+ command: {
+ type: 'turn.start',
+ sessionId: 'sess_1',
+ text: 'run the test suite',
+ attachments: [],
+ },
})
})
})
@@ -705,7 +722,9 @@ describe('SessionView per-turn diff cards', () => {
turnId: 'turn_1',
})
})
- expect(await screen.findByRole('button', { name: 'Turn diff: 1 file changed' })).toBeInTheDocument()
+ expect(
+ await screen.findByRole('button', { name: 'Turn diff: 1 file changed' }),
+ ).toBeInTheDocument()
})
it('shows no card when the settled turn has a null diff', async () => {
@@ -831,7 +850,12 @@ describe('SessionView context meter', () => {
undefined}
/>
,
@@ -935,8 +959,12 @@ describe('SessionView replay/live dedupe (M23.12)', () => {
emitFrame({ event: userMessage(1, 'who are you') })
emitFrame({
event: {
- seq: 2, at: 2, sessionId: 'sess_1', type: 'assistant.parts.appended',
- messageId: 'm2', parts: [{ type: 'text', text: 'I am the agent.' }],
+ seq: 2,
+ at: 2,
+ sessionId: 'sess_1',
+ type: 'assistant.parts.appended',
+ messageId: 'm2',
+ parts: [{ type: 'text', text: 'I am the agent.' }],
},
})
@@ -944,8 +972,12 @@ describe('SessionView replay/live dedupe (M23.12)', () => {
emitFrame({ event: userMessage(1, 'who are you'), replay: true })
emitFrame({
event: {
- seq: 2, at: 2, sessionId: 'sess_1', type: 'assistant.parts.appended',
- messageId: 'm2', parts: [{ type: 'text', text: 'I am the agent.' }],
+ seq: 2,
+ at: 2,
+ sessionId: 'sess_1',
+ type: 'assistant.parts.appended',
+ messageId: 'm2',
+ parts: [{ type: 'text', text: 'I am the agent.' }],
},
replay: true,
})
@@ -1023,7 +1055,12 @@ describe('SessionView queued messages', () => {
await user.type(screen.getByLabelText('Message'), 'second prompt{Enter}')
expect(invokeMock).toHaveBeenCalledWith('command.dispatch', {
- command: { type: 'message.enqueue', sessionId: 'sess_1', text: 'second prompt', attachments: [] },
+ command: {
+ type: 'message.enqueue',
+ sessionId: 'sess_1',
+ text: 'second prompt',
+ attachments: [],
+ },
})
emitSessionEvent({
@@ -1055,9 +1092,7 @@ describe('SessionView queued messages', () => {
errorMessage: null,
})
await waitFor(() => {
- expect(
- invokeMock.mock.calls.some(([method]) => method === 'command.dispatch'),
- ).toBe(false)
+ expect(invokeMock.mock.calls.some(([method]) => method === 'command.dispatch')).toBe(false)
})
// Engine dequeues the next message; a fresh turn begins.
@@ -1222,7 +1257,9 @@ describe('SessionView image attachments', () => {
if (method === 'command.dispatch') return { accepted: true }
if (method === 'attachments.stage') return { attachments: [REF] }
if (method === 'attachments.read') {
- return { attachment: { name: 'shot.png', mimeType: 'image/png', size: 8, dataBase64: 'aGk=' } }
+ return {
+ attachment: { name: 'shot.png', mimeType: 'image/png', size: 8, dataBase64: 'aGk=' },
+ }
}
throw new Error(`unexpected method: ${String(method)}`)
})
@@ -1248,13 +1285,20 @@ describe('SessionView image attachments', () => {
const input = await screen.findByLabelText('Message')
fireEvent.paste(input, {
- clipboardData: { files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]) },
+ clipboardData: {
+ files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]),
+ },
})
await user.type(input, 'look at this{Enter}')
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith('command.dispatch', {
- command: { type: 'turn.start', sessionId: 'sess_1', text: 'look at this', attachments: [REF] },
+ command: {
+ type: 'turn.start',
+ sessionId: 'sess_1',
+ text: 'look at this',
+ attachments: [REF],
+ },
})
})
const stageCall = invokeMock.mock.calls.find(([method]) => method === 'attachments.stage')
@@ -1278,7 +1322,13 @@ describe('SessionView image attachments', () => {
turnId: 'turn_1',
role: 'user',
parts: [
- { type: 'image', attachmentId: 'att_1', name: 'shot.png', mimeType: 'image/png', size: 8 },
+ {
+ type: 'image',
+ attachmentId: 'att_1',
+ name: 'shot.png',
+ mimeType: 'image/png',
+ size: 8,
+ },
{ type: 'text', text: 'look at this' },
],
createdAt: 1,
@@ -1308,7 +1358,9 @@ describe('SessionView image attachments', () => {
const input = await screen.findByLabelText('Message')
fireEvent.paste(input, {
- clipboardData: { files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]) },
+ clipboardData: {
+ files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]),
+ },
})
await user.type(input, 'do not lose this{Enter}')
@@ -1335,7 +1387,9 @@ describe('SessionView image attachments', () => {
if (method === 'command.dispatch') return { accepted: false, reason: 'over capacity' }
if (method === 'attachments.stage') return { attachments: [REF] }
if (method === 'attachments.read') {
- return { attachment: { name: 'shot.png', mimeType: 'image/png', size: 8, dataBase64: 'aGk=' } }
+ return {
+ attachment: { name: 'shot.png', mimeType: 'image/png', size: 8, dataBase64: 'aGk=' },
+ }
}
throw new Error(`unexpected method: ${String(method)}`)
})
@@ -1343,7 +1397,9 @@ describe('SessionView image attachments', () => {
const input = await screen.findByLabelText('Message')
fireEvent.paste(input, {
- clipboardData: { files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]) },
+ clipboardData: {
+ files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]),
+ },
})
await user.type(input, 'look at this{Enter}')
@@ -1426,7 +1482,9 @@ describe('SessionView refused sends with review notes', () => {
await user.click((await screen.findAllByRole('button', { name: 'Comment on src/a.ts:1' }))[0]!)
await user.type(screen.getByLabelText('Review note for src/a.ts'), 'extract a helper')
await user.click(screen.getByRole('button', { name: 'Save note' }))
- expect(await screen.findByLabelText('Review notes attached to next message')).toBeInTheDocument()
+ expect(
+ await screen.findByLabelText('Review notes attached to next message'),
+ ).toBeInTheDocument()
await user.type(screen.getByLabelText('Message'), 'do it{Enter}')
expect(await screen.findByText('over capacity')).toBeInTheDocument()
@@ -1582,9 +1640,7 @@ describe('EffortChip', () => {
})
it('hides when the harness advertised no thought levels', async () => {
- invokeMock.mockResolvedValue([
- { kind: 'grok', source: 'live', models: [], efforts: [] },
- ])
+ invokeMock.mockResolvedValue([{ kind: 'grok', source: 'live', models: [], efforts: [] }])
render( undefined} />)
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('providers.models'))
expect(screen.queryByRole('button', { name: /Effort:/ })).not.toBeInTheDocument()
@@ -1643,6 +1699,179 @@ describe('EffortChip', () => {
await user.click(screen.getByRole('option', { name: /High/ }))
expect(onChange).toHaveBeenCalledWith('high')
})
+
+ it('re-queries the selected model when the catalog has no levels', async () => {
+ invokeMock.mockImplementation(async (method: string) => {
+ if (method === 'providers.models') {
+ return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }]
+ }
+ if (method === 'providers.efforts') {
+ return {
+ efforts: [
+ { id: 'low', label: 'Low' },
+ { id: 'high', label: 'High' },
+ ],
+ }
+ }
+ throw new Error(`unexpected method: ${method}`)
+ })
+ render(
+ undefined}
+ />,
+ )
+ expect(await screen.findByRole('button', { name: 'Effort: Low' })).toBeInTheDocument()
+ expect(invokeMock).toHaveBeenCalledWith('providers.efforts', {
+ kind: 'opencode',
+ modelId: 'opencode/muse-spark-1.3-contributor-free',
+ })
+ })
+
+ it('keeps a saved effort that only the model-specific list advertises', async () => {
+ const onChange = vi.fn()
+ // The per-model answer is held open so the component really does sit in
+ // the gap the generic catalog opens, which is where the effort was lost.
+ let releaseEfforts: (() => void) | undefined
+ const efforts = new Promise<{ efforts: { id: string; label: string }[] }>((resolve) => {
+ releaseEfforts = () =>
+ resolve({
+ efforts: [
+ { id: 'low', label: 'Low' },
+ { id: 'high', label: 'High' },
+ ],
+ })
+ })
+ invokeMock.mockImplementation(async (method: string) => {
+ // The kind's catalog was probed against the default model and knows
+ // nothing of `high`; only the per-model query does.
+ if (method === 'providers.models') {
+ return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }]
+ }
+ if (method === 'providers.efforts') return efforts
+ throw new Error(`unexpected method: ${method}`)
+ })
+
+ render(
+ ,
+ )
+
+ // The generic catalog has landed and knows nothing of `high`; the saved
+ // effort must survive until the model's own list has had its say.
+ await waitFor(() =>
+ expect(invokeMock).toHaveBeenCalledWith('providers.efforts', {
+ kind: 'opencode',
+ modelId: 'opencode/reasoner-1',
+ }),
+ )
+ expect(onChange).not.toHaveBeenCalled()
+
+ await act(async () => {
+ releaseEfforts?.()
+ await efforts
+ })
+
+ expect(await screen.findByRole('button', { name: 'Effort: High' })).toBeInTheDocument()
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('still clears an effort no list advertises once both queries have settled', async () => {
+ const onChange = vi.fn()
+ invokeMock.mockImplementation(async (method: string) => {
+ if (method === 'providers.models') {
+ return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }]
+ }
+ if (method === 'providers.efforts') {
+ return { efforts: [{ id: 'low', label: 'Low' }] }
+ }
+ throw new Error(`unexpected method: ${method}`)
+ })
+
+ render(
+ ,
+ )
+
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(null))
+ })
+
+ it('keeps a saved effort across a model switch until the new model answers', async () => {
+ const onChange = vi.fn()
+ let releaseNext: (() => void) | undefined
+ const nextEfforts = new Promise<{ efforts: { id: string; label: string }[] }>((resolve) => {
+ releaseNext = () =>
+ resolve({
+ efforts: [
+ { id: 'low', label: 'Low' },
+ { id: 'high', label: 'High' },
+ ],
+ })
+ })
+ invokeMock.mockImplementation(async (method: string, params?: unknown) => {
+ if (method === 'providers.models') {
+ return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }]
+ }
+ if (method === 'providers.efforts') {
+ const modelId =
+ typeof params === 'object' && params !== null && 'modelId' in params
+ ? (params as { modelId?: string }).modelId
+ : undefined
+ if (modelId === 'opencode/reasoner-2') return nextEfforts
+ return {
+ efforts: [
+ { id: 'low', label: 'Low' },
+ { id: 'high', label: 'High' },
+ ],
+ }
+ }
+ throw new Error(`unexpected method: ${method}`)
+ })
+
+ const { rerender } = render(
+ ,
+ )
+ expect(await screen.findByRole('button', { name: 'Effort: High' })).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+ await waitFor(() =>
+ expect(invokeMock).toHaveBeenCalledWith('providers.efforts', {
+ kind: 'opencode',
+ modelId: 'opencode/reasoner-2',
+ }),
+ )
+ expect(onChange).not.toHaveBeenCalled()
+
+ await act(async () => {
+ releaseNext?.()
+ await nextEfforts
+ })
+
+ expect(await screen.findByRole('button', { name: 'Effort: High' })).toBeInTheDocument()
+ expect(onChange).not.toHaveBeenCalled()
+ })
})
/**
diff --git a/apps/desktop/src/renderer/src/features/session/SessionView.tsx b/apps/desktop/src/renderer/src/features/session/SessionView.tsx
index bc834ecc..fdebc19e 100644
--- a/apps/desktop/src/renderer/src/features/session/SessionView.tsx
+++ b/apps/desktop/src/renderer/src/features/session/SessionView.tsx
@@ -986,6 +986,7 @@ export function SessionView({
/>
@@ -1021,13 +1022,19 @@ interface EffortOption {
* (`thought_level`, `effort`, `reasoning_effort`, or a thinking-shaped mode
* list). Hidden when the agent advertises nothing — we never invent
* low/medium/high.
+ *
+ * Effort can be per-model (OpenCode only advertises it for
+ * reasoning-capable models), so with a model selected the chip re-queries
+ * that model's own levels and prefers them over the default-model catalog.
*/
export function EffortChip({
driverKind,
+ modelId,
effort,
onChange,
}: {
driverKind: DriverKind
+ modelId?: string | null
effort: string | null
onChange: (effort: string | null) => void
}) {
@@ -1047,22 +1054,49 @@ export function EffortChip({
useEffect(() => {
let cancelled = false
+ // A model-specific probe is the authority on this model's vocabulary, so
+ // `loaded` is withheld until it settles. Releasing it on the generic
+ // catalog alone lets the reset effect below clear a saved effort that only
+ // the model's own list advertises — the very case this control exists for.
+ // Reset on every generation: leaving `loaded` true across a model switch
+ // lets the catalog's first paint clear a saved effort the new model still
+ // advertises.
+ setLoaded(false)
+ const probesModel = modelId !== undefined && modelId !== null && modelId.length > 0
const apply = (rows: { kind: string; efforts?: EffortOption[] }[]): void => {
if (cancelled) return
const row = rows.find((r) => r.kind === driverKind)
setOptions(row?.efforts ?? [])
- setLoaded(true)
+ if (!probesModel) setLoaded(true)
}
const load = (): void => {
- void rpc
+ const base = rpc
.invoke('providers.models')
.then(apply)
.catch(() => {
if (!cancelled) {
setOptions([])
- setLoaded(true)
+ if (!probesModel) setLoaded(true)
}
})
+ // A selected model may advertise thought levels the default-model
+ // catalog probe never saw; a non-empty answer wins, an empty one
+ // keeps whatever the catalog reported.
+ if (probesModel) {
+ void base.finally(() => {
+ if (cancelled) return
+ void rpc
+ .invoke('providers.efforts', { kind: driverKind, modelId })
+ .then((result) => {
+ if (cancelled) return
+ if (result.efforts.length > 0) setOptions(result.efforts)
+ })
+ .catch(() => undefined)
+ .finally(() => {
+ if (!cancelled) setLoaded(true)
+ })
+ })
+ }
}
load()
const unsubscribe = rpc.subscribe('providers.updates', {}, (payload) => {
@@ -1073,7 +1107,7 @@ export function EffortChip({
cancelled = true
unsubscribe()
}
- }, [driverKind])
+ }, [driverKind, modelId])
useEffect(() => {
if (!loaded || effort === null) return
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 7e5647ed..5760e96a 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -451,6 +451,16 @@ export const rpcParams = {
'attachments.read': z.object({ id: z.string().min(1).max(128) }),
'providers.detect': z.undefined(),
'providers.models': z.undefined(),
+ /**
+ * Thought/reasoning levels for one kind, optionally resolved against a
+ * specific model. Effort is per-model on some agents (OpenCode advertises
+ * `effort` only for reasoning-capable models), so the picker re-queries
+ * with the selected model instead of trusting the default-model probe.
+ */
+ 'providers.efforts': z.object({
+ kind: driverKindSchema,
+ modelId: z.string().min(1).max(256).nullish(),
+ }),
'providers.plan': z.object({ kind: driverKindSchema }),
'providers.install': z.object({
kind: driverKindSchema,
@@ -704,6 +714,13 @@ export interface RpcResults {
current?: boolean
}[]
}[]
+ /**
+ * Thought/reasoning levels for one kind, resolved live against the
+ * requested model when given; empty when the harness has no selector.
+ */
+ 'providers.efforts': {
+ efforts: { id: string; label: string; description?: string; current?: boolean }[]
+ }
/**
* The literal argv Ari would run to install or upgrade a provider CLI, so
* the confirm dialog can show the exact command before anything executes.
diff --git a/packages/providers/src/acp/acp-driver.test.ts b/packages/providers/src/acp/acp-driver.test.ts
index 25044d1a..b4853117 100644
--- a/packages/providers/src/acp/acp-driver.test.ts
+++ b/packages/providers/src/acp/acp-driver.test.ts
@@ -313,6 +313,77 @@ describe('createAcpAdapter', () => {
await adapter.dispose()
}, 15000)
+ it('re-queries effort from the options a model switch returns', async () => {
+ const child = fakeChild()
+ script(child, (method, params, id) => {
+ if (method === 'session/new') {
+ return {
+ sessionId: 'sess_acp_1',
+ configOptions: [
+ {
+ id: 'model',
+ name: 'Model',
+ category: 'model',
+ type: 'select',
+ currentValue: 'plain',
+ options: [
+ { value: 'plain', name: 'Plain' },
+ { value: 'thinker', name: 'Thinker' },
+ ],
+ },
+ ],
+ }
+ }
+ if (method === 'session/set_config_option') {
+ const p = params as { configId?: string; value?: string }
+ if (p.configId === 'model') {
+ return {
+ configOptions: [
+ {
+ id: 'model',
+ name: 'Model',
+ category: 'model',
+ type: 'select',
+ currentValue: 'thinker',
+ options: [
+ { value: 'plain', name: 'Plain' },
+ { value: 'thinker', name: 'Thinker' },
+ ],
+ },
+ {
+ id: 'effort',
+ name: 'Effort',
+ category: 'thought_level',
+ type: 'select',
+ currentValue: 'medium',
+ options: [
+ { value: 'low', name: 'Low' },
+ { value: 'medium', name: 'Medium' },
+ { value: 'high', name: 'High' },
+ ],
+ },
+ ],
+ }
+ }
+ return { configOptions: [] as unknown[] }
+ }
+ return standardAgent()(method, params, id)
+ })
+ const adapter = await createAcpAdapter(
+ LAUNCH,
+ { ...SESSION, modelId: 'thinker', effort: 'high' },
+ () => child,
+ )
+ expect(adapter.advertisedEfforts.options.map((o) => o.id)).toEqual(['low', 'medium', 'high'])
+ const thought = child.sent.filter(
+ (m) =>
+ m['method'] === 'session/set_config_option' &&
+ (m['params'] as { configId?: string })?.configId === 'effort',
+ ) as { params?: { value?: string } }[]
+ expect(thought.map((r) => r.params?.value)).toEqual(['high'])
+ await adapter.dispose()
+ }, 15000)
+
it('sets pi-style thinking modes via session/set_mode for an effort pick', async () => {
const child = fakeChild()
script(child, PI_THINKING_AGENT)
diff --git a/packages/providers/src/acp/acp-driver.ts b/packages/providers/src/acp/acp-driver.ts
index 4b098052..ab28b3ae 100644
--- a/packages/providers/src/acp/acp-driver.ts
+++ b/packages/providers/src/acp/acp-driver.ts
@@ -22,13 +22,19 @@ import {
replyPermissionChoice,
replyPlanExit,
} from './client-requests'
-import { findThoughtOption, looksLikeThoughtAxis } from './thought'
+import { findThoughtOption, looksLikeThoughtAxis, thoughtEffortsFromSession } from './thought'
+import type { EffortCatalog } from './thought'
import { classifyAgentMode, findModeOption, pickAgentMode } from './modes'
export { pickAgentMode } from './modes'
import { loadImageData, missingImagesNote, stagedImagesOf } from '../attachments'
import type { CatalogModel } from '../catalogs'
-import { setDynamicModels } from '../catalogs'
-import { AcpUpdateFolder, configOptionsFromUpdate, currentModelId, stopReasonEvents } from './protocol'
+import { setDynamicEfforts, setDynamicModels } from '../catalogs'
+import {
+ AcpUpdateFolder,
+ configOptionsFromUpdate,
+ currentModelId,
+ stopReasonEvents,
+} from './protocol'
import type {
AcpConfigOption,
AcpInitializeResult,
@@ -54,6 +60,14 @@ export interface AcpAdapter extends ProviderAdapter {
respondApproval(approvalId: string, decision: AdapterApprovalDecision): void
/** Answers a pending `input-requested` question (elicitation, ask-user, plan). */
respondInput(inputId: string, value: string): void
+ /**
+ * Thought/reasoning levels the session advertises after the model pick was
+ * applied. Effort is per-model on some agents (OpenCode exposes `effort`
+ * only for reasoning-capable models), so the host refreshes the kind's
+ * live effort catalog from this: the picker re-queries after a model
+ * switch instead of trusting the default-model probe forever.
+ */
+ advertisedEfforts: EffortCatalog
/**
* The models this session's agent advertises, in the agent's own vocabulary.
* The host installs these as the kind's live catalog: discovery from the
@@ -189,7 +203,8 @@ export async function createAcpAdapter(
if (method === 'elicitation/create') {
const form = parseElicitationForm(params)
if (form.url) return Promise.resolve(replyElicitationCancel())
- const prompt = form.message.length > 0 ? form.message : (form.questions[0]?.question ?? 'Question')
+ const prompt =
+ form.message.length > 0 ? form.message : (form.questions[0]?.question ?? 'Question')
return parkInput(
form.questions.length > 1 ? form.message || `${form.questions.length} questions` : prompt,
encodeQuestionnaire({ kind: 'questionnaire', questions: form.questions }),
@@ -248,7 +263,10 @@ export async function createAcpAdapter(
if (typeof resumeId === 'string' && resumeId.length > 0) {
if (sessionResumeSupported(connection.initialize)) {
try {
- return { created: await connection.resumeSession(resumeId, session.workspacePath), resumed: true }
+ return {
+ created: await connection.resumeSession(resumeId, session.workspacePath),
+ resumed: true,
+ }
} catch (error) {
log.warn('acp: session/resume failed; trying session/load', {
resumeId,
@@ -258,7 +276,10 @@ export async function createAcpAdapter(
}
if (connection.initialize.agentCapabilities?.loadSession === true) {
try {
- return { created: await connection.loadSession(resumeId, session.workspacePath), resumed: true }
+ return {
+ created: await connection.loadSession(resumeId, session.workspacePath),
+ resumed: true,
+ }
} catch (error) {
log.warn('acp: session/load failed; starting a fresh session', {
resumeId,
@@ -286,10 +307,7 @@ export async function createAcpAdapter(
// folder has the exact prelude before that notification can be folded.
folder.setStartupInfo(created._meta?.piAcp?.startupInfo)
const sessionId = created.sessionId as string
- const drift = new ModelDriftWatch(
- session.modelId,
- currentModelId(created.configOptions ?? null),
- )
+ const drift = new ModelDriftWatch(session.modelId, currentModelId(created.configOptions ?? null))
connection.onSessionUpdate = (notification) => {
push(folder.fold(notification))
// The agent announcing a configuration move has no transcript surface, but
@@ -306,12 +324,30 @@ export async function createAcpAdapter(
const selectors = resolveSelectors(launch.label, created, resumed)
try {
- const applied = await applyModel(connection, sessionId, selectors.configOptions, session.modelId)
+ const applied = await applyModel(
+ connection,
+ sessionId,
+ selectors.configOptions,
+ session.modelId,
+ )
// The pick did not reach the agent, so a different model is about to
// answer. Say it in the transcript: the composer still shows the pick.
if (applied.kind === 'unavailable' && session.modelId !== null) {
push([modelUnavailableNotice(session.modelId, applied.offered)])
}
+ // A model switch can reshape the session's own selectors: OpenCode only
+ // advertises `effort` for reasoning-capable models, so the options that
+ // came back with the switch replace the default-model vocabulary for
+ // everything below (permission modes, thought level) and for the picker.
+ // An empty reply carries no information (older agents answer `{}`), so it
+ // must not wipe the vocabulary the session opened with.
+ if (applied.kind === 'applied' && applied.refreshed !== null) {
+ selectors.configOptions = applied.refreshed
+ LEARNED_SELECTORS.set(launch.label, {
+ configOptions: selectors.configOptions,
+ availableModes: selectors.availableModes,
+ })
+ }
await applyPermissionMode(
connection,
sessionId,
@@ -338,7 +374,8 @@ export async function createAcpAdapter(
* on a request whose client has already left.
*/
const releasePending = (): void => {
- for (const pending of pendingPermissions.values()) pending.resolve({ outcome: { outcome: 'cancelled' } })
+ for (const pending of pendingPermissions.values())
+ pending.resolve({ outcome: { outcome: 'cancelled' } })
pendingPermissions.clear()
// Answer parked interactive requests as a JSON-RPC *success* cancel.
// A method-not-found or a dropped request is what Grok reads as
@@ -431,7 +468,11 @@ export async function createAcpAdapter(
if (event.type === 'done') return
}
if (connection.closed && queue.length === 0) {
- yield { type: 'error', message: `${launch.label} ended before completing the turn`, rawJson: null }
+ yield {
+ type: 'error',
+ message: `${launch.label} ended before completing the turn`,
+ rawJson: null,
+ }
yield { type: 'done' }
return
}
@@ -496,6 +537,13 @@ export async function createAcpAdapter(
if (!connection.closed) connection.cancel(sessionId)
await connection.shutdown()
},
+ advertisedEfforts: thoughtEffortsFromSession({
+ configOptions: selectors.configOptions,
+ modes:
+ selectors.availableModes.length > 0
+ ? { availableModes: selectors.availableModes }
+ : undefined,
+ }),
advertisedModels: modelsFromConfigOptions(selectors.configOptions),
}
}
@@ -554,10 +602,7 @@ function formatAcpSetupError(error: unknown): string {
* flavor the agent offered, in priority order. Falls back to undefined so
* the caller can cancel instead of fabricating an option id.
*/
-function optionFor(
- options: AcpRequestPermission['options'],
- kinds: string[],
-): string | undefined {
+function optionFor(options: AcpRequestPermission['options'], kinds: string[]): string | undefined {
const list = options ?? []
for (const kind of kinds) {
const match = list.find((o) => o.kind === kind && typeof o.optionId === 'string')
@@ -621,7 +666,8 @@ export function __resetLearnedAcpSelectors(): void {
* agent defaults to.
*/
type ModelApplyOutcome =
- | { kind: 'applied' }
+ /** `refreshed` holds the selectors the agent returned with the switch, or null when it said nothing useful. */
+ | { kind: 'applied'; refreshed: AcpConfigOption[] | null }
| { kind: 'skipped' }
| { kind: 'unavailable'; offered: string[] }
@@ -649,14 +695,21 @@ async function applyModel(
}
}
try {
- await connection.setConfigOption(sessionId, option.id as string, value)
+ // The reply carries the selectors as they stand *after* the switch, which
+ // is how a per-model effort axis is discovered. Older agents answer `{}`;
+ // an empty list says nothing, so it is reported as no refresh at all.
+ const refreshed = await connection.setConfigOption(sessionId, option.id as string, value)
+ return {
+ kind: 'applied',
+ refreshed:
+ refreshed !== null && refreshed !== undefined && refreshed.length > 0 ? refreshed : null,
+ }
} catch (error) {
log.debug('acp: set_config_option(model) failed', { error: String(error) })
// The agent took the request and refused it — from the user's side that is
// the same outcome as never offering the model: a different one answered.
return { kind: 'unavailable', offered: [] }
}
- return { kind: 'applied' }
}
/**
@@ -796,7 +849,9 @@ async function applyThoughtLevel(
}
return
}
- const ids = availableModes.map((m) => m.id).filter((v): v is string => typeof v === 'string' && v.length > 0)
+ const ids = availableModes
+ .map((m) => m.id)
+ .filter((v): v is string => typeof v === 'string' && v.length > 0)
if (looksLikeThoughtAxis(ids) && ids.includes(effort)) {
try {
await connection.setMode(sessionId, effort)
@@ -858,6 +913,17 @@ export function publishAdvertisedModels(kind: DriverKind, models: CatalogModel[]
* while adapters drift — except for the auth walls {@link shouldFallBack}
* excludes, which propagate as {@link AcpAuthRequiredError}.
*/
+/**
+ * Installs the thought/reasoning levels the running session advertises after
+ * its model pick, so the effort picker follows model switches. An empty
+ * catalog clears a stale overlay (a model without effort genuinely offers
+ * none); kinds with a static fallback (Grok, Ari Core) fall back to it.
+ */
+export function publishAdvertisedEfforts(kind: DriverKind, efforts: EffortCatalog): void {
+ setDynamicEfforts(kind, efforts)
+ log.info('effort catalog taken from the session agent', { kind, count: efforts.options.length })
+}
+
export class AcpDriver implements Driver {
readonly kind: DriverKind
@@ -881,6 +947,7 @@ export class AcpDriver implements Driver {
this.onAuthRequired ?? undefined,
)
log.info('turn started over ACP', { kind: this.kind, launch: this.launch.label })
+ publishAdvertisedEfforts(this.kind, adapter.advertisedEfforts)
publishAdvertisedModels(this.kind, adapter.advertisedModels)
return adapter
} catch (error) {