mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-26 18:20:04 +00:00
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import type { McpServer } from "@agentclientprotocol/sdk"
|
|
import { Schema } from "effect"
|
|
import { currentValue } from "./select-options"
|
|
import { makeSession, rpcError, secondModel, startWire, type Wire } from "./wire-fixture"
|
|
|
|
describe("acp session lifecycle over the wire", () => {
|
|
test("initialize advertises capabilities, negotiates the version, and offers terminal auth only when asked", async () => {
|
|
await using acp = await startWire()
|
|
|
|
const plain = await acp.initialize()
|
|
const terminal = await acp.initialize({ terminalAuth: true, childSessionUpdates: true })
|
|
const standard = await acp.request("initialize", {
|
|
protocolVersion: 1,
|
|
clientCapabilities: { auth: { terminal: true } },
|
|
})
|
|
|
|
expect(plain).toMatchObject({
|
|
protocolVersion: 1,
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
mcpCapabilities: { http: true, sse: false },
|
|
promptCapabilities: { embeddedContext: true, image: true },
|
|
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
|
_meta: { "opencode/child-session-updates": true },
|
|
},
|
|
agentInfo: { name: "OpenCode" },
|
|
})
|
|
expect(plain.authMethods).toEqual([
|
|
{ id: "opencode-login", name: "Login with opencode", description: "Run `opencode auth login` in the terminal" },
|
|
])
|
|
expect(terminal.authMethods?.[0]?._meta).toEqual({
|
|
"terminal-auth": { command: "opencode", args: ["auth", "login"], label: "OpenCode Login" },
|
|
})
|
|
expect(standard.authMethods).toEqual([
|
|
{
|
|
id: "opencode-login",
|
|
name: "Login with opencode",
|
|
description: "Run `opencode auth login` in the terminal",
|
|
type: "terminal",
|
|
args: ["--login"],
|
|
},
|
|
])
|
|
expect(await acp.request("authenticate", { methodId: "opencode-login" })).toEqual({})
|
|
expect(await rpcError(acp.request("authenticate", { methodId: "missing" }))).toMatchObject({
|
|
code: -32602,
|
|
data: { methodId: "missing" },
|
|
})
|
|
expect((await acp.request("initialize", { protocolVersion: 99 })).protocolVersion).toBe(1)
|
|
})
|
|
|
|
test("loads with paginated replay while resume and fork do not replay", async () => {
|
|
await using acp = await startWire()
|
|
const history = Array.from({ length: 201 }, (_, index) => ({
|
|
id: `msg_${index}`,
|
|
type: "user" as const,
|
|
text: `message ${index}`,
|
|
time: { created: index },
|
|
}))
|
|
acp.server.sessions.set(
|
|
"ses_loaded",
|
|
makeSession("ses_loaded", {
|
|
agent: "plan",
|
|
model: { providerID: "test", id: secondModel.id, variant: "medium" },
|
|
}),
|
|
)
|
|
acp.server.messages.set("ses_loaded", history)
|
|
acp.server.sessions.set(
|
|
"ses_resume",
|
|
makeSession("ses_resume", { agent: "plan", model: { providerID: "test", id: secondModel.id, variant: "low" } }),
|
|
)
|
|
acp.server.messages.set("ses_resume", [{ id: "msg_resume", type: "user", text: "hidden", time: { created: 1 } }])
|
|
await acp.initialize()
|
|
|
|
const loaded = await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })
|
|
const resumed = await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_resume", mcpServers: [] })
|
|
const forked = await acp.request("session/fork", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })
|
|
|
|
expect(
|
|
await rpcError(acp.request("session/load", { cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })),
|
|
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded", cwd: "/elsewhere" } })
|
|
const sessions = new Set(acp.server.sessions.keys())
|
|
expect(
|
|
await rpcError(acp.request("session/fork", { cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })),
|
|
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded", cwd: "/elsewhere" } })
|
|
expect(new Set(acp.server.sessions.keys())).toEqual(sessions)
|
|
expect(
|
|
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_missing", mcpServers: [] })),
|
|
).toMatchObject({ code: -32602, data: { sessionId: "ses_missing" } })
|
|
expect(currentValue(loaded, "model")).toBe("test/second-model")
|
|
expect(currentValue(loaded, "effort")).toBe("medium")
|
|
expect(currentValue(loaded, "mode")).toBe("plan")
|
|
expect(currentValue(resumed, "effort")).toBe("low")
|
|
expect(currentValue(forked, "effort")).toBe("medium")
|
|
expect(acp.server.sessions.has(forked.sessionId)).toBe(true)
|
|
const replayed = (sessionId: string) =>
|
|
acp.updates.flatMap((item) =>
|
|
item.sessionId === sessionId && item.update.sessionUpdate === "user_message_chunk"
|
|
? [item.update.messageId]
|
|
: [],
|
|
)
|
|
expect(replayed("ses_loaded")).toEqual(history.map((message) => message.id))
|
|
expect(replayed(forked.sessionId)).toEqual([])
|
|
expect(replayed("ses_resume")).toEqual([])
|
|
expect(
|
|
acp.updates.find((item) => item.sessionId === "ses_loaded" && item.update.sessionUpdate === "user_message_chunk")
|
|
?.update,
|
|
).toEqual({
|
|
sessionUpdate: "user_message_chunk",
|
|
messageId: "msg_0",
|
|
content: { type: "text", text: "message 0" },
|
|
})
|
|
})
|
|
|
|
test("publishes a session's commands after the response that attaches it", async () => {
|
|
await using acp = await startWire()
|
|
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
|
|
acp.server.messages.set("ses_loaded", [{ id: "msg_0", type: "user", text: "hello", time: { created: 0 } }])
|
|
await acp.initialize()
|
|
const params = { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }
|
|
|
|
expect(await untilCommands(acp, () => acp.newSession())).toEqual(["response", "available_commands_update"])
|
|
expect(await untilCommands(acp, () => acp.request("session/load", params))).toEqual([
|
|
"user_message_chunk",
|
|
"response",
|
|
"available_commands_update",
|
|
])
|
|
expect(await untilCommands(acp, () => acp.request("session/resume", params))).toEqual([
|
|
"response",
|
|
"available_commands_update",
|
|
])
|
|
expect(await untilCommands(acp, () => acp.request("session/fork", params))).toEqual([
|
|
"response",
|
|
"available_commands_update",
|
|
])
|
|
})
|
|
|
|
test("converts MCP configs and deduplicates registrations per session and config", 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" }],
|
|
}
|
|
await using acp = await startWire()
|
|
await acp.initialize()
|
|
|
|
const first = await acp.newSession("/workspace", [local, local, remote])
|
|
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [local, remote] })
|
|
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [changed] })
|
|
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [local] })
|
|
|
|
const localConfig = (args: string[]) => ({
|
|
name: "tools",
|
|
directory: "/workspace",
|
|
config: { type: "local", command: ["bun", ...args], environment: { TOKEN: "x" } },
|
|
})
|
|
expect(acp.server.mcp.filter((item) => item.name === "tools")).toEqual([
|
|
localConfig(["server.ts"]),
|
|
localConfig(["changed.ts"]),
|
|
localConfig(["server.ts"]),
|
|
])
|
|
|
|
await acp.newSession("/workspace", [local])
|
|
|
|
expect(acp.server.mcp).toHaveLength(5)
|
|
expect(acp.server.mcp.filter((item) => item.name === "tools")).toEqual([
|
|
localConfig(["server.ts"]),
|
|
localConfig(["changed.ts"]),
|
|
localConfig(["server.ts"]),
|
|
localConfig(["server.ts"]),
|
|
])
|
|
expect(acp.server.mcp.find((item) => item.name === "docs")).toEqual({
|
|
name: "docs",
|
|
directory: "/workspace",
|
|
config: { type: "remote", url: "https://example.com/mcp", headers: { Authorization: "Bearer x" }, oauth: false },
|
|
})
|
|
})
|
|
test("rejects relative cwds, MCP-over-ACP, and SSE servers before creating or loading a session", async () => {
|
|
await using acp = await startWire()
|
|
acp.server.sessions.set("ses_saved", makeSession("ses_saved"))
|
|
await acp.initialize()
|
|
const existing = new Set(acp.server.sessions.keys())
|
|
const mcpServers: McpServer[] = [{ type: "acp", name: "client", serverId: "mcp_client" }]
|
|
const sse: McpServer[] = [{ type: "sse", name: "events", url: "https://example.com/sse", headers: [] }]
|
|
const invalid = {
|
|
code: -32602,
|
|
message: "Invalid params: Only stdio and HTTP MCP servers are supported",
|
|
data: { field: "mcpServers" },
|
|
}
|
|
const relative = {
|
|
code: -32602,
|
|
message: "Invalid params: cwd must be an absolute path: workspace",
|
|
data: { field: "cwd" },
|
|
}
|
|
|
|
expect(await rpcError(acp.newSession("/workspace", mcpServers))).toEqual(invalid)
|
|
expect(await rpcError(acp.newSession("/workspace", sse))).toEqual(invalid)
|
|
expect(
|
|
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers })),
|
|
).toEqual(invalid)
|
|
expect(await rpcError(acp.newSession("workspace"))).toEqual(relative)
|
|
expect(
|
|
await rpcError(acp.request("session/load", { cwd: "workspace", sessionId: "ses_saved", mcpServers: [] })),
|
|
).toEqual(relative)
|
|
expect(await rpcError(acp.request("session/list", { cwd: "workspace" }))).toEqual(relative)
|
|
expect(await rpcError(acp.request("session/list", { cwd: "" }))).toMatchObject({
|
|
code: -32602,
|
|
data: { field: "cwd" },
|
|
})
|
|
expect(new Set(acp.server.sessions.keys())).toEqual(existing)
|
|
expect(acp.server.requests.filter((request) => request.path.includes("ses_saved"))).toEqual([])
|
|
expect(acp.logs).toEqual([])
|
|
})
|
|
})
|
|
|
|
const isSessionUpdate = Schema.is(
|
|
Schema.Struct({
|
|
method: Schema.Literal("session/update"),
|
|
params: Schema.Struct({ update: Schema.Struct({ sessionUpdate: Schema.String }) }),
|
|
}),
|
|
)
|
|
|
|
// Labels what the agent sends from the request until the commands that follow it, in wire order.
|
|
async function untilCommands(acp: Wire, send: () => Promise<unknown>) {
|
|
const start = acp.received.length
|
|
await send()
|
|
return acp.until(() => {
|
|
const labels = acp.received.slice(start).map((message) => {
|
|
if (isSessionUpdate(message)) return message.params.update.sessionUpdate
|
|
return "method" in message ? message.method : "response"
|
|
})
|
|
return labels.includes("available_commands_update") && labels
|
|
}, "available commands")
|
|
}
|