mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
|
import {
|
|
buildAgent,
|
|
makeACPFixture,
|
|
makeSession,
|
|
planAgent,
|
|
reviewCommand,
|
|
secondModel,
|
|
testModel,
|
|
} from "./service-fixture"
|
|
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
|
|
|
|
describe("acp service directory behavior", () => {
|
|
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
|
let created = 0
|
|
await using fixture = makeACPFixture({
|
|
fetch(request) {
|
|
if (request.method !== "POST" || request.path !== "/api/session") return undefined
|
|
created++
|
|
return Response.json({
|
|
data: makeSession(`ses_${created}`, { cwd: created <= 2 ? "/workspace" : "/other" }),
|
|
})
|
|
},
|
|
})
|
|
|
|
const first = await Promise.all([
|
|
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
|
|
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
|
|
])
|
|
const other = await fixture.service.newSession({ cwd: "/other", mcpServers: [] })
|
|
|
|
expect(first.map((session) => session.sessionId).toSorted()).toEqual(["ses_1", "ses_2"])
|
|
expect(other.sessionId).toBe("ses_3")
|
|
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) =>
|
|
fixture.requests
|
|
.filter((request) => request.path === path)
|
|
.map((request) => request.query["location[directory]"]),
|
|
),
|
|
).toEqual([
|
|
["/workspace", "/other"],
|
|
["/workspace", "/other"],
|
|
["/workspace", "/other"],
|
|
["/workspace", "/other"],
|
|
])
|
|
expect(
|
|
fixture.requests
|
|
.filter((request) => request.method === "POST" && request.path === "/api/session")
|
|
.map((request) => request.body),
|
|
).toEqual([
|
|
{ location: { directory: "/workspace" } },
|
|
{ location: { directory: "/workspace" } },
|
|
{ location: { directory: "/other" } },
|
|
])
|
|
expect(
|
|
fixture.updates.map((item) =>
|
|
item.update.sessionUpdate === "available_commands_update"
|
|
? item.update.availableCommands.map((command) => command.name)
|
|
: [],
|
|
),
|
|
).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"])(
|
|
"retries when the model list is %s but the default is ready",
|
|
async (initial) => {
|
|
await using fixture = makeACPFixture({
|
|
fetch(request, context) {
|
|
if (
|
|
request.path === "/api/model" &&
|
|
context.requests.filter((request) => request.path === "/api/model").length === 1
|
|
) {
|
|
return Response.json({
|
|
location: {
|
|
directory: "/workspace",
|
|
project: { id: "global", directory: "/workspace", canonical: "/workspace" },
|
|
},
|
|
data: initial === "empty" ? [] : [secondModel],
|
|
})
|
|
}
|
|
if (request.method === "POST" && request.path === "/api/session") {
|
|
return Response.json({ data: makeSession("ses_ready") })
|
|
}
|
|
return undefined
|
|
},
|
|
})
|
|
|
|
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
|
const model = requireSelectOption(session.configOptions, "model")
|
|
const choices = flattenSelectOptions(model).map((option) => option.value)
|
|
|
|
expect(choices).toContain("test/second-model")
|
|
expect(choices).toContain("test/test-model")
|
|
expect(model.currentValue).toBe("test/test-model")
|
|
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(2)
|
|
},
|
|
)
|
|
|
|
test("does not cache a failed catalog load", async () => {
|
|
let modelCalls = 0
|
|
await using fixture = makeACPFixture({
|
|
fetch(request) {
|
|
if (request.path === "/api/model") {
|
|
modelCalls++
|
|
if (modelCalls === 1) {
|
|
return Response.json(
|
|
{ name: "ModelsNotReadyError", data: { message: "catalog is warming" } },
|
|
{ status: 503 },
|
|
)
|
|
}
|
|
}
|
|
if (request.method === "POST" && request.path === "/api/session") {
|
|
return Response.json({ data: makeSession("ses_retry") })
|
|
}
|
|
return undefined
|
|
},
|
|
})
|
|
|
|
const failure = await fixture.service
|
|
.newSession({ cwd: "/workspace", mcpServers: [] })
|
|
.catch((error: unknown) => error)
|
|
expect(failure).toMatchObject({ name: "ModelsNotReadyError" })
|
|
const retried = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
|
|
|
expect(retried.sessionId).toBe("ses_retry")
|
|
expect(modelCalls).toBe(2)
|
|
})
|
|
|
|
test("switches model, effort, and mode against the warm catalog", async () => {
|
|
await using fixture = makeACPFixture({
|
|
fetch(request) {
|
|
if (request.method === "POST" && request.path === "/api/session") {
|
|
return Response.json({ data: makeSession("ses_config") })
|
|
}
|
|
if (
|
|
request.method === "POST" &&
|
|
(request.path === "/api/session/ses_config/model" || request.path === "/api/session/ses_config/agent")
|
|
) {
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
return undefined
|
|
},
|
|
})
|
|
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
|
|
|
const selectedModel = await fixture.service.setSessionConfigOption({
|
|
sessionId: session.sessionId,
|
|
configId: "model",
|
|
value: "test/second-model",
|
|
})
|
|
const selectedEffort = await fixture.service.setSessionConfigOption({
|
|
sessionId: session.sessionId,
|
|
configId: "effort",
|
|
value: "medium",
|
|
})
|
|
const selectedMode = await fixture.service.setSessionConfigOption({
|
|
sessionId: session.sessionId,
|
|
configId: "mode",
|
|
value: "plan",
|
|
})
|
|
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
|
|
|
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
|
expect(currentValue(selectedModel, "effort")).toBe("default")
|
|
expect(currentValue(selectedEffort, "effort")).toBe("medium")
|
|
expect(currentValue(selectedMode, "mode")).toBe("plan")
|
|
expect(
|
|
fixture.requests
|
|
.filter((request) => request.path === "/api/session/ses_config/model")
|
|
.map((request) => request.body),
|
|
).toEqual([
|
|
{ model: { providerID: "test", id: secondModel.id } },
|
|
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
|
])
|
|
expect(
|
|
fixture.requests
|
|
.filter((request) => request.path === "/api/session/ses_config/agent")
|
|
.map((request) => request.body),
|
|
).toEqual([{ agent: "plan" }, { agent: "build" }])
|
|
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(1)
|
|
|
|
const invalidEffort = await fixture.service
|
|
.setSessionConfigOption({
|
|
sessionId: session.sessionId,
|
|
configId: "effort",
|
|
value: "maximum",
|
|
})
|
|
.catch((error: unknown) => error)
|
|
const invalidMode = await fixture.service
|
|
.setSessionConfigOption({
|
|
sessionId: session.sessionId,
|
|
configId: "mode",
|
|
value: "missing",
|
|
})
|
|
.catch((error: unknown) => error)
|
|
const invalidConfig = await fixture.service
|
|
.setSessionConfigOption({
|
|
sessionId: session.sessionId,
|
|
configId: "missing",
|
|
value: "value",
|
|
})
|
|
.catch((error: unknown) => error)
|
|
expect(invalidEffort).toMatchObject({ _tag: "ACPInvalidEffortError" })
|
|
expect(invalidMode).toMatchObject({ _tag: "ACPInvalidModeError" })
|
|
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
|
|
})
|
|
|
|
test("registers MCP configs for the session on every attach", async () => {
|
|
const local: McpServer = {
|
|
name: "tools",
|
|
command: "bun",
|
|
args: ["server.ts"],
|
|
env: [{ name: "TOKEN", value: "x" }],
|
|
}
|
|
const changed: McpServer = { ...local, args: ["changed.ts"] }
|
|
const remote: McpServer = {
|
|
type: "http",
|
|
name: "docs",
|
|
url: "https://example.com/mcp",
|
|
headers: [{ name: "Authorization", value: "Bearer x" }],
|
|
}
|
|
let created = 0
|
|
const mcp = /^\/api\/experimental\/session\/(ses_\d+)\/mcp\/(\w+)$/
|
|
await using fixture = makeACPFixture({
|
|
fetch(request) {
|
|
if (request.method === "POST" && request.path === "/api/session") {
|
|
created++
|
|
return Response.json({ data: makeSession(`ses_${created}`) })
|
|
}
|
|
if (request.method === "GET" && request.path === "/api/session/ses_1") {
|
|
return Response.json({ data: makeSession("ses_1") })
|
|
}
|
|
if (request.method === "PUT" && mcp.test(request.path)) {
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
return undefined
|
|
},
|
|
})
|
|
|
|
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, remote] })
|
|
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
|
|
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
|
|
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
|
|
|
|
const adds = fixture.requests.filter((request) => request.method === "PUT" && mcp.test(request.path))
|
|
expect(adds.map((request) => request.path.match(mcp)?.slice(1))).toEqual([
|
|
["ses_1", "tools"],
|
|
["ses_1", "docs"],
|
|
["ses_1", "tools"],
|
|
["ses_1", "docs"],
|
|
["ses_1", "tools"],
|
|
["ses_2", "tools"],
|
|
])
|
|
expect(adds.filter((request) => request.path.endsWith("/mcp/tools")).map((request) => request.body)).toEqual([
|
|
{
|
|
config: {
|
|
type: "local",
|
|
command: ["bun", "server.ts"],
|
|
environment: { TOKEN: "x" },
|
|
},
|
|
},
|
|
{
|
|
config: {
|
|
type: "local",
|
|
command: ["bun", "server.ts"],
|
|
environment: { TOKEN: "x" },
|
|
},
|
|
},
|
|
{
|
|
config: {
|
|
type: "local",
|
|
command: ["bun", "changed.ts"],
|
|
environment: { TOKEN: "x" },
|
|
},
|
|
},
|
|
{
|
|
config: {
|
|
type: "local",
|
|
command: ["bun", "server.ts"],
|
|
environment: { TOKEN: "x" },
|
|
},
|
|
},
|
|
])
|
|
expect(adds.find((request) => request.path.endsWith("/mcp/docs"))?.body).toEqual({
|
|
config: {
|
|
type: "remote",
|
|
url: "https://example.com/mcp",
|
|
headers: { Authorization: "Bearer x" },
|
|
oauth: false,
|
|
},
|
|
})
|
|
expect(adds.map((request) => request.query)).toEqual([{}, {}, {}, {}, {}, {}])
|
|
})
|
|
})
|
|
|
|
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,
|
|
) {
|
|
return result?.configOptions?.find((option) => option.id === id)?.currentValue
|
|
}
|