mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(acp): follow server defaults and refresh the session catalog (#52286)
This commit is contained in:
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user