fix(acp): follow server defaults and refresh the session catalog (#52286)

This commit is contained in:
Shoubhit Dash
2026-09-30 18:36:56 +05:30
committed by GitHub
parent c0285b457a
commit 1844c11104
5 changed files with 386 additions and 170 deletions
+139
View File
@@ -0,0 +1,139 @@
import type { CommandInfo, ModelInfo, ModelRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import type { ConfigOptionProvider } from "./config-option"
export type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
readonly defaultModel: ModelRef
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
readonly commands: CommandInfo[]
}
export type Live = {
readonly cwd: string
current: Catalog
}
// Provider, integration, and credential changes reach the catalog through model.updated.
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
export function make(input: {
readonly client: OpenCodeClient
readonly signal?: AbortSignal
readonly changed: (live: Live, previous: Catalog) => Promise<unknown>
}) {
const entries = new Map<string, Promise<Live>>()
const running = new Map<Live, Promise<void>>()
const queued = new Set<Live>()
let subscribed: Promise<void> | undefined
const get = (cwd: string) => {
const key = FSUtil.resolve(cwd)
const cached = entries.get(key)
if (cached) return cached
// Subscribe before the first read so an update between the read and the subscription is not lost.
const loaded = subscribe()
.then(() => load(input.client, cwd))
.then((current): Live => ({ cwd, current }))
.catch((error) => {
entries.delete(key)
throw error
})
entries.set(key, loaded)
return loaded
}
const reload = (live: Live) => {
const current = running.get(live)
if (current) {
queued.add(live)
return current
}
const run = (async () => {
do {
queued.delete(live)
const next = await load(input.client, live.cwd).catch(() => undefined)
if (!next) break
const previous = live.current
live.current = next
await input.changed(live, previous).catch(() => {})
} while (queued.has(live))
running.delete(live)
})()
running.set(live, run)
return run
}
const subscribe = () =>
(subscribed ??= new Promise<void>((ready) => {
void (async () => {
for await (const event of input.client.event.subscribe({ signal: input.signal })) {
if (event.type === "server.connected") ready()
if (!reloadOn.has(event.type)) continue
const directory = event.location?.directory
const targets = directory === undefined ? [...entries.values()] : [entries.get(FSUtil.resolve(directory))]
targets.forEach((entry) => void entry?.then(reload, () => {}))
}
})()
.catch(() => {})
.finally(ready)
}))
return { get, reload }
}
async function load(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Some providers discover models in the background after plugin startup begins.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// Parallel reads can straddle initialization; select only from this model list.
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
if (defaultModel && defaultAgent) {
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
}
}
missing = defaultModel ? "No primary agents are available" : "No models are available"
await Bun.sleep(25)
}
throw new Error(missing)
}
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
id: providerID,
name: providerID,
models: models
.filter((model) => model.providerID === providerID)
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
}))
}
export * as ACPCatalog from "./catalog"
+107 -138
View File
@@ -1,7 +1,7 @@
import { isDeepStrictEqual } from "node:util"
import {
isSessionNotFoundError,
type CommandInfo,
type ModelInfo,
type ModelRef,
type OpenCodeClient,
type SessionInfo,
@@ -40,12 +40,8 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode/schema/session-message"
import {
buildConfigOptions,
DEFAULT_VARIANT_VALUE,
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import { ACPCatalog, type Catalog } from "./catalog"
import { buildConfigOptions, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
import type { ACPConnection } from "./connection"
import { promptContentToParts } from "./content"
import {
@@ -61,22 +57,14 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
readonly defaultModel: ModelRef
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
readonly commands: CommandInfo[]
}
// Model and mode are unset while the session follows the server defaults.
type Attached = {
readonly id: string
readonly cwd: string
readonly abort: AbortController
catalog: Catalog
model: ModelRef
modeID: string
readonly catalog: ACPCatalog.Live
model?: ModelRef
modeID?: string
}
type PreparedPrompt = {
@@ -109,21 +97,55 @@ export function make(input: {
readonly connection: ACPConnection.Connection
}): Interface {
const sessions = new Map<string, Attached>()
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
const capabilities = { writeTextFile: false, childSessionUpdates: false }
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
if (cached) return cached
const loaded = loadCatalog(input.client, cwd).catch((error) => {
catalogs.delete(cwd)
throw error
const catalogs = ACPCatalog.make({
client: input.client,
signal: input.connection.signal,
changed: (live, previous) =>
Promise.all(
Array.from(sessions.values())
.filter((state) => state.catalog === live)
.map(async (state) => {
const options = configOptions(state)
if (!isDeepStrictEqual(options, configOptions(state, previous))) {
await input.connection.sessionUpdate({
sessionId: state.id,
update: { sessionUpdate: "config_option_update", configOptions: options },
})
}
if (!isDeepStrictEqual(live.current.commands, previous.commands)) await sendCommands(state)
}),
),
})
const sendCommands = (state: Attached) =>
input.connection.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: state.catalog.current.commands.map((command) => ({
name: command.name,
description: command.description ?? "",
})),
},
})
const withReload = <A>(state: Attached, select: () => Promise<A>) =>
select().catch(async (error: unknown) => {
if (
!(
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.InvalidEffortError
)
)
throw error
await catalogs.reload(state.catalog)
return select()
})
catalogs.set(cwd, loaded)
return loaded
}
const requireSession = async (sessionID: string) => {
const current = sessions.get(sessionID)
@@ -147,27 +169,19 @@ export function make(input: {
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
const catalog = await catalogs.get(cwd)
sessions.get(session.id)?.abort.abort()
const state: Attached = {
id: session.id,
cwd,
abort: new AbortController(),
catalog: currentCatalog,
model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID,
catalog,
model: session.model,
modeID: session.agent,
}
sessions.set(session.id, state)
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
await input.connection.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
...state.catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
],
},
})
await sendCommands(state)
return state
}
@@ -175,14 +189,16 @@ export function make(input: {
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
}
const configOptions = (state: Attached) =>
buildConfigOptions({
providers: state.catalog.providers,
currentModel: { providerID: state.model.providerID, modelID: state.model.id },
currentVariant: state.model.variant,
modes: state.catalog.modes,
currentModeId: state.modeID,
const configOptions = (state: Attached, catalog = state.catalog.current) => {
const model = currentModel(state, catalog)
return buildConfigOptions({
providers: catalog.providers,
currentModel: { providerID: model.providerID, modelID: model.id },
currentVariant: model.variant,
modes: catalog.modes,
currentModeId: state.modeID ?? catalog.defaultModeID,
})
}
return {
initialize: async (params) => {
@@ -216,12 +232,10 @@ export function make(input: {
return {}
},
newSession: async (params) => {
const currentCatalog = await catalog(params.cwd)
const created = await input.client.session.create({
location: { directory: params.cwd },
agent: currentCatalog.defaultModeID,
model: currentCatalog.defaultModel,
})
// Load before creating so a catalog failure leaves no session behind. Agent and model stay unset
// so the server resolves its defaults after plugins activate.
await catalogs.get(params.cwd)
const created = await input.client.session.create({ location: { directory: params.cwd } })
const state = await attach(created, params.cwd, params.mcpServers)
return { sessionId: state.id, configOptions: configOptions(state) }
},
@@ -279,37 +293,38 @@ export function make(input: {
},
setSessionConfigOption: async (params) => {
const state = await requireSession(params.sessionId)
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value, state.model)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
const value = params.value
if (typeof value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
await withReload(state, async () => {
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog.current, value, currentModel(state))
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
return
}
case "effort": {
const current = currentModel(state)
const model = state.catalog.current.models.find(
(item) => item.providerID === current.providerID && item.id === current.id,
)
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
throw new ACPError.InvalidEffortError({ effort: value })
state.model = { ...current, variant: value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
return
}
case "mode":
return selectMode(input.client, state, value)
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
case "effort": {
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (
!model ||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
)
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
break
}
case "mode":
await selectMode(input.client, state, params.value)
break
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
})
return { configOptions: configOptions(state) }
},
setSessionMode: async (params) => {
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
const state = await requireSession(params.sessionId)
await withReload(state, () => selectMode(input.client, state, params.modeId))
return {}
},
prompt: async (params, signal) => {
@@ -321,7 +336,7 @@ export function make(input: {
})
}
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const prepared = preparePrompt(state.catalog.current, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const extNotification = input.connection.extNotification
const childSessionUpdate =
@@ -410,58 +425,6 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart
return { type: "input", id: messageID }
}
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Some providers discover models in the background after plugin startup begins.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// Parallel reads can straddle initialization; select only from this model list.
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
if (defaultModel && defaultAgent) {
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
}
}
missing = defaultModel ? "No primary agents are available" : "No models are available"
await Bun.sleep(25)
}
throw new Error(missing)
}
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
id: providerID,
name: providerID,
models: models
.filter((model) => model.providerID === providerID)
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
}))
}
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
@@ -480,8 +443,13 @@ function requireModel(catalog: Catalog, modelID: string, current: ModelRef): Mod
return { providerID: model.providerID, id: model.id, variant }
}
function currentModel(state: Attached, catalog = state.catalog.current) {
return state.model ?? catalog.defaultModel
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
if (!state.catalog.current.modes.some((mode) => mode.id === modeID))
throw new ACPError.InvalidModeError({ mode: modeID })
state.modeID = modeID
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
}
@@ -567,8 +535,9 @@ async function sendUsageUpdate(
used?: number,
) {
if (!used) return
const model = session.catalog.models.find(
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
const current = currentModel(session)
const model = session.catalog.current.models.find(
(item) => item.providerID === current.providerID && item.id === current.id,
)
if (!model?.limit.context) return
const info = await client.session.get({ sessionID: session.id })
+138 -27
View File
@@ -1,6 +1,14 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
import {
buildAgent,
makeACPFixture,
makeSession,
planAgent,
reviewCommand,
secondModel,
testModel,
} from "./service-fixture"
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
describe("acp service directory behavior", () => {
@@ -27,12 +35,7 @@ describe("acp service directory behavior", () => {
expect(currentValue(first[0], "model")).toBe("test/test-model")
expect(currentValue(first[0], "mode")).toBe("build")
expect(
[
"/api/model",
"/api/model/default",
"/api/agent",
"/api/command",
].map((path) =>
["/api/model", "/api/model/default", "/api/agent", "/api/command"].map((path) =>
fixture.requests
.filter((request) => request.path === path)
.map((request) => request.query["location[directory]"]),
@@ -48,21 +51,9 @@ describe("acp service directory behavior", () => {
.filter((request) => request.method === "POST" && request.path === "/api/session")
.map((request) => request.body),
).toEqual([
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/workspace" },
},
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/workspace" },
},
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/other" },
},
{ location: { directory: "/workspace" } },
{ location: { directory: "/workspace" } },
{ location: { directory: "/other" } },
])
expect(
fixture.updates.map((item) =>
@@ -70,11 +61,116 @@ describe("acp service directory behavior", () => {
? item.update.availableCommands.map((command) => command.name)
: [],
),
).toEqual([
["review"],
["review"],
["review"],
])
).toEqual([["review"], ["review"], ["review"]])
})
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
const configured = { ...buildAgent, id: "copilot-build", name: "copilot-build" }
const catalog = { agents: [buildAgent, planAgent], commands: [reviewCommand] }
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
const location = { directory: request.query["location[directory]"] ?? "/workspace" }
if (request.path === "/api/agent") return Response.json({ location, data: catalog.agents })
if (request.path === "/api/command") return Response.json({ location, data: catalog.commands })
if (request.method !== "POST" || request.path !== "/api/session") return undefined
created++
return Response.json({ data: { ...makeSession(`ses_${created}`), agent: undefined, model: undefined } })
},
})
const first = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(currentValue(first, "mode")).toBe("build")
expect(currentValue(first, "model")).toBe("test/test-model")
const agentReads = () => fixture.requests.filter((request) => request.path === "/api/agent").length
const reads = agentReads()
fixture.send({ id: "evt_other", created: 1, type: "agent.updated", location: { directory: "/other" }, data: {} })
catalog.agents = [configured, buildAgent, planAgent]
catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
fixture.send({
id: "evt_agent",
created: 2,
type: "agent.updated",
location: { directory: "/workspace" },
data: {},
})
const update = await until(() =>
fixture.updates.find(
(item) => item.sessionId === first.sessionId && item.update.sessionUpdate === "config_option_update",
),
)
expect(update.update.sessionUpdate === "config_option_update" && modeOption(update.update.configOptions)).toEqual({
currentValue: "copilot-build",
options: ["copilot-build", "build", "plan"],
})
const commands = await until(() =>
fixture.updates.findLast(
(item) =>
item.sessionId === first.sessionId &&
item.update.sessionUpdate === "available_commands_update" &&
item.update.availableCommands.length === 2,
),
)
expect(
commands.update.sessionUpdate === "available_commands_update" &&
commands.update.availableCommands.map((command) => command.name),
).toEqual(["review", "ship"])
expect(agentReads()).toBe(reads + 1)
const second = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(currentValue(second, "mode")).toBe("copilot-build")
expect(
fixture.requests
.filter((request) => request.method === "POST" && request.path === "/api/session")
.map((request) => request.body),
).toEqual([{ location: { directory: "/workspace" } }, { location: { directory: "/workspace" } }])
})
test("reloads the catalog before rejecting a model or mode it has not seen", async () => {
const configured = { ...planAgent, id: "copilot-build", name: "copilot-build" }
const catalog = { models: [testModel], agents: [buildAgent, planAgent] }
await using fixture = makeACPFixture({
defaultModel: testModel,
fetch(request) {
const location = { directory: request.query["location[directory]"] ?? "/workspace" }
if (request.path === "/api/model") return Response.json({ location, data: catalog.models })
if (request.path === "/api/agent") return Response.json({ location, data: catalog.agents })
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_reload") })
}
if (
request.method === "POST" &&
(request.path === "/api/session/ses_reload/model" || request.path === "/api/session/ses_reload/agent")
) {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
catalog.models = [testModel, secondModel]
catalog.agents = [buildAgent, planAgent, configured]
const model = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "copilot-build" })
const missing = await fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "mode", value: "missing" })
.catch((error: unknown) => error)
expect(currentValue(model, "model")).toBe("test/second-model")
expect(
fixture.requests
.filter((request) => request.path === "/api/session/ses_reload/agent")
.map((request) => request.body),
).toEqual([{ agent: "copilot-build" }])
expect(missing).toMatchObject({ _tag: "ACPInvalidModeError" })
expect(fixture.requests.filter((request) => request.path === "/api/agent")).toHaveLength(3)
})
test.each(["empty", "missing the default"])(
@@ -300,6 +396,21 @@ describe("acp service directory behavior", () => {
})
})
function modeOption(options: SessionConfigOption[]) {
const mode = requireSelectOption(options, "mode")
return { currentValue: mode.currentValue, options: flattenSelectOptions(mode).map((option) => option.value) }
}
async function until<Value>(read: () => Value | undefined) {
const deadline = Date.now() + 2_000
while (Date.now() < deadline) {
const value = read()
if (value !== undefined) return value
await Bun.sleep(5)
}
throw new Error("timed out waiting for ACP update")
}
function currentValue(
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
id: string,
+1
View File
@@ -184,6 +184,7 @@ export function makeACPFixture(options: FixtureOptions = {}) {
service,
requests,
updates,
send: context.send,
async [Symbol.asyncDispose]() {
eventController?.close()
await server.stop(true)
@@ -26,11 +26,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session",
query: {},
body: {
location: { directory: "/workspace" },
agent: "build",
model: { providerID: "test", id: "second-model" },
},
body: { location: { directory: "/workspace" } },
})
expect(currentValue(created, "effort")).toBe("default")
})