Skip to content

Commit a5ea910

Browse files
authored
fix(acp-next): add config switch fast paths (#29255)
1 parent 56743dc commit a5ea910

4 files changed

Lines changed: 398 additions & 3 deletions

File tree

packages/opencode/src/acp-next/agent.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
type LoadSessionRequest,
99
type NewSessionRequest,
1010
type PromptRequest,
11+
type SetSessionConfigOptionRequest,
12+
type SetSessionModelRequest,
13+
type SetSessionModeRequest,
1114
} from "@agentclientprotocol/sdk"
1215
import { Effect } from "effect"
1316
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
@@ -41,6 +44,18 @@ export class Agent implements ACPAgent {
4144
return run(this.service.loadSession(params))
4245
}
4346

47+
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
48+
return run(this.service.setSessionConfigOption(params))
49+
}
50+
51+
setSessionMode(params: SetSessionModeRequest) {
52+
return run(this.service.setSessionMode(params))
53+
}
54+
55+
unstable_setSessionModel(params: SetSessionModelRequest) {
56+
return run(this.service.setSessionModel(params))
57+
}
58+
4459
prompt(params: PromptRequest) {
4560
return run(this.service.prompt(params))
4661
}

packages/opencode/src/acp-next/service.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,18 @@ import {
1313
type NewSessionResponse,
1414
type PromptRequest,
1515
type PromptResponse,
16+
type SetSessionConfigOptionRequest,
17+
type SetSessionConfigOptionResponse,
18+
type SetSessionModelRequest,
19+
type SetSessionModelResponse,
20+
type SetSessionModeRequest,
21+
type SetSessionModeResponse,
1622
} from "@agentclientprotocol/sdk"
1723
import { InstallationVersion } from "@opencode-ai/core/installation/version"
1824
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
1925
import { Context, Effect, Layer, ManagedRuntime } from "effect"
2026
import * as ACPNextError from "./error"
21-
import { buildConfigOptions } from "./config-option"
27+
import { buildConfigOptions, parseModelSelection } from "./config-option"
2228
import { Directory } from "./directory"
2329
import { ACPNextSession } from "./session"
2430
import { ModelID, ProviderID } from "@/provider/schema"
@@ -34,6 +40,11 @@ export type Interface = {
3440
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
3541
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
3642
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
43+
readonly setSessionConfigOption: (
44+
input: SetSessionConfigOptionRequest,
45+
) => Effect.Effect<SetSessionConfigOptionResponse, Error>
46+
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Error>
47+
readonly setSessionModel: (input: SetSessionModelRequest) => Effect.Effect<SetSessionModelResponse, Error>
3748
readonly prompt: (input: PromptRequest) => Effect.Effect<PromptResponse, Error>
3849
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
3950
}
@@ -180,11 +191,96 @@ export function make(input: {
180191
}
181192
})
182193

194+
const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* (
195+
params: SetSessionConfigOptionRequest,
196+
) {
197+
const current = yield* session.get(params.sessionId)
198+
const snapshot = yield* directorySnapshot(current.cwd)
199+
if (typeof params.value !== "string") {
200+
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
201+
}
202+
203+
if (params.configId === "model") {
204+
const selected = yield* parseSelectedModel(snapshot, params.value)
205+
const variant = selected.variant ?? selectVariant(snapshot, selected.model)
206+
const state = yield* session
207+
.setVariant(params.sessionId, Directory.variants(snapshot, selected.model) ? variant : undefined)
208+
.pipe(Effect.andThen(session.setModel(params.sessionId, selected.model)))
209+
return {
210+
configOptions: configOptions(snapshot, {
211+
model: state.model ?? selected.model,
212+
variant: state.variant,
213+
modeId: state.modeId,
214+
}),
215+
}
216+
}
217+
218+
if (params.configId === "effort") {
219+
const model = current.model ?? selectDefaultModel(snapshot)
220+
const variants = Directory.variants(snapshot, model)
221+
if (!variants || !Object.keys(variants).includes(params.value)) {
222+
return yield* new ACPNextError.InvalidEffortError({ effort: params.value })
223+
}
224+
const state = yield* session.setVariant(params.sessionId, params.value)
225+
return {
226+
configOptions: configOptions(snapshot, {
227+
model: state.model ?? model,
228+
variant: state.variant,
229+
modeId: state.modeId,
230+
}),
231+
}
232+
}
233+
234+
if (params.configId === "mode") {
235+
if (!snapshot.availableModes.some((mode) => mode.id === params.value)) {
236+
return yield* new ACPNextError.InvalidModeError({ mode: params.value })
237+
}
238+
const state = yield* session.setMode(params.sessionId, params.value)
239+
return {
240+
configOptions: configOptions(snapshot, {
241+
model: state.model ?? selectDefaultModel(snapshot),
242+
variant: state.variant,
243+
modeId: state.modeId,
244+
}),
245+
}
246+
}
247+
248+
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
249+
})
250+
251+
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
252+
const current = yield* session.get(params.sessionId)
253+
const snapshot = yield* directorySnapshot(current.cwd)
254+
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
255+
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
256+
}
257+
yield* session.setMode(params.sessionId, params.modeId)
258+
return {}
259+
})
260+
261+
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
262+
const current = yield* session.get(params.sessionId)
263+
const snapshot = yield* directorySnapshot(current.cwd)
264+
const selected = yield* parseSelectedModel(snapshot, params.modelId)
265+
yield* session
266+
.setVariant(
267+
params.sessionId,
268+
Directory.variants(snapshot, selected.model)
269+
? (selected.variant ?? selectVariant(snapshot, selected.model))
270+
: undefined,
271+
)
272+
.pipe(Effect.andThen(session.setModel(params.sessionId, selected.model)))
273+
return {}
274+
})
275+
183276
return {
184277
initialize,
185278
authenticate,
186279
newSession,
187280
loadSession,
281+
setSessionConfigOption,
282+
setSessionMode,
283+
setSessionModel,
188284
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
189285
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
190286
}),
@@ -371,6 +467,30 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) {
371467
})
372468
}
373469

470+
function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
471+
const selected = parseModelSelection(modelId, Object.values(snapshot.providers))
472+
const provider = snapshot.providers[ProviderID.make(selected.model.providerID)]
473+
const model = provider?.models[ModelID.make(selected.model.modelID)]
474+
if (!model) {
475+
return Effect.fail(
476+
new ACPNextError.InvalidModelError({
477+
providerId: selected.model.providerID,
478+
modelId,
479+
}),
480+
)
481+
}
482+
if (selected.variant && !model.variants?.[selected.variant]) {
483+
return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant }))
484+
}
485+
return Effect.succeed({
486+
model: {
487+
providerID: provider.id,
488+
modelID: model.id,
489+
},
490+
variant: selected.variant,
491+
})
492+
}
493+
374494
function sendAvailableCommands(
375495
connection: Pick<AgentSideConnection, "sessionUpdate"> | undefined,
376496
sessionId: string,

packages/opencode/test/acp-next/service-session.test.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { describe, expect, it } from "bun:test"
2-
import type { AgentSideConnection, LoadSessionResponse, NewSessionResponse } from "@agentclientprotocol/sdk"
2+
import type {
3+
AgentSideConnection,
4+
LoadSessionResponse,
5+
NewSessionResponse,
6+
SessionConfigOption,
7+
SessionConfigSelectOption,
8+
SetSessionConfigOptionResponse,
9+
} from "@agentclientprotocol/sdk"
310
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
411
import { Effect } from "effect"
512
import * as ACPNextService from "@/acp-next/service"
@@ -10,6 +17,7 @@ import type { Provider } from "@/provider/provider"
1017
const providerID = ProviderID.make("test")
1118
const modelID = ModelID.make("test-model")
1219
const configuredModelID = ModelID.make("configured-model")
20+
const secondModelID = ModelID.make("second-model")
1321

1422
const provider: Provider.Info = {
1523
id: providerID,
@@ -88,6 +96,43 @@ const provider: Provider.Info = {
8896
headers: {},
8997
release_date: "2026-01-01",
9098
},
99+
[secondModelID]: {
100+
id: secondModelID,
101+
providerID,
102+
api: {
103+
id: secondModelID,
104+
url: "https://example.com",
105+
npm: "@ai-sdk/openai-compatible",
106+
},
107+
name: "Second Model",
108+
family: "test",
109+
capabilities: {
110+
temperature: true,
111+
reasoning: true,
112+
attachment: false,
113+
toolcall: true,
114+
input: { text: true, audio: false, image: false, video: false, pdf: false },
115+
output: { text: true, audio: false, image: false, video: false, pdf: false },
116+
interleaved: false,
117+
},
118+
cost: {
119+
input: 0,
120+
output: 0,
121+
cache: { read: 0, write: 0 },
122+
},
123+
limit: {
124+
context: 128000,
125+
output: 4096,
126+
},
127+
status: "active",
128+
options: {},
129+
headers: {},
130+
release_date: "2026-01-01",
131+
variants: {
132+
low: { reasoningEffort: "low" },
133+
medium: { reasoningEffort: "medium" },
134+
},
135+
},
91136
},
92137
}
93138

@@ -359,8 +404,131 @@ describe("ACP next service sessions", () => {
359404
expect(result.sessionId).toBe("configured-model")
360405
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
361406
})
407+
408+
it("switches model and returns updated model and effort options", async () => {
409+
const { service } = makeService()
410+
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
411+
const updated = await Effect.runPromise(
412+
service.setSessionConfigOption({
413+
sessionId: session.sessionId,
414+
configId: "model",
415+
value: "test/second-model",
416+
}),
417+
)
418+
419+
expect(select(updated, "model")?.currentValue).toBe("test/second-model")
420+
expect(select(updated, "effort")?.currentValue).toBe("low")
421+
expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual(["low", "medium"])
422+
})
423+
424+
it("switches effort and returns the updated effort current value", async () => {
425+
const { service } = makeService()
426+
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
427+
const updated = await Effect.runPromise(
428+
service.setSessionConfigOption({
429+
sessionId: session.sessionId,
430+
configId: "effort",
431+
value: "high",
432+
}),
433+
)
434+
435+
expect(select(updated, "effort")?.currentValue).toBe("high")
436+
})
437+
438+
it("switches mode and returns the updated mode current value", async () => {
439+
const { service } = makeService()
440+
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
441+
const updated = await Effect.runPromise(
442+
service.setSessionConfigOption({
443+
sessionId: session.sessionId,
444+
configId: "mode",
445+
value: "plan",
446+
}),
447+
)
448+
449+
expect(select(updated, "mode")?.currentValue).toBe("plan")
450+
})
451+
452+
it("maps invalid model effort mode and config id to invalid params", async () => {
453+
const { service } = makeService()
454+
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
455+
456+
const results = await Promise.all(
457+
[
458+
{ configId: "model", value: "test/missing-model" },
459+
{ configId: "effort", value: "max" },
460+
{ configId: "mode", value: "missing-mode" },
461+
{ configId: "missing", value: "value" },
462+
].map((input) =>
463+
Effect.runPromise(
464+
service
465+
.setSessionConfigOption({ sessionId: session.sessionId, ...input })
466+
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
467+
),
468+
),
469+
)
470+
expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
471+
})
472+
473+
it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
474+
let providersCalls = 0
475+
let commandCalls = 0
476+
const sdk = {
477+
config: {
478+
providers: () => {
479+
providersCalls++
480+
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
481+
},
482+
get: () => Promise.resolve({ data: {} }),
483+
},
484+
app: {
485+
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
486+
skills: () => Promise.resolve({ data: [] }),
487+
},
488+
command: {
489+
list: () => {
490+
commandCalls++
491+
return Promise.resolve({ data: [] })
492+
},
493+
},
494+
session: {
495+
create: () => Promise.resolve({ data: { id: "ses_fast" } }),
496+
list: () => Promise.resolve({ data: [] }),
497+
},
498+
mcp: {
499+
add: () => Promise.resolve({ data: {} }),
500+
},
501+
} as unknown as OpencodeClient
502+
const service = ACPNextService.make({ sdk })
503+
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
504+
505+
expect(providersCalls).toBe(1)
506+
expect(commandCalls).toBe(1)
507+
508+
await Effect.runPromise(
509+
service.setSessionConfigOption({
510+
sessionId: session.sessionId,
511+
configId: "effort",
512+
value: "high",
513+
}),
514+
)
515+
516+
expect(providersCalls).toBe(1)
517+
expect(commandCalls).toBe(1)
518+
})
362519
})
363520

364521
function categories(result: NewSessionResponse | LoadSessionResponse) {
365522
return result.configOptions?.map((option) => option.category) ?? []
366523
}
524+
525+
function select(result: SetSessionConfigOptionResponse, id: string) {
526+
return result.configOptions.find(
527+
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
528+
option.id === id && option.type === "select",
529+
)
530+
}
531+
532+
function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }> | undefined) {
533+
return option?.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options)) ?? []
534+
}

0 commit comments

Comments
 (0)