mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
224 lines
9.6 KiB
TypeScript
224 lines
9.6 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import type { McpServer } from "@agentclientprotocol/sdk"
|
|
import { currentValue } from "./select-options"
|
|
import { makeSession, rpcError, secondModel, startSession, startWire } from "./wire-fixture"
|
|
|
|
describe("acp session lifecycle over the wire", () => {
|
|
test("initialize advertises capabilities and terminal auth only when the client asks", async () => {
|
|
await using acp = await startWire()
|
|
|
|
const plain = await acp.initialize()
|
|
const terminal = await acp.initialize({ terminalAuth: true, childSessionUpdates: true })
|
|
|
|
expect(plain).toMatchObject({
|
|
protocolVersion: 1,
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
mcpCapabilities: { http: true, sse: false },
|
|
promptCapabilities: { embeddedContext: true, image: true },
|
|
sessionCapabilities: { 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(await acp.request("authenticate", { methodId: "opencode-login" })).toEqual({})
|
|
expect(await rpcError(acp.request("authenticate", { methodId: "missing" }))).toMatchObject({
|
|
code: -32602,
|
|
data: { methodId: "missing" },
|
|
})
|
|
})
|
|
|
|
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
|
await using acp = await startWire()
|
|
acp.server.catalog.commands = [{ name: "review" }]
|
|
await acp.initialize()
|
|
|
|
const result = await acp.newSession("/workspace", [
|
|
{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] },
|
|
])
|
|
|
|
expect(acp.server.sessions.get(result.sessionId)?.location.directory).toBe("/workspace")
|
|
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
|
expect(acp.server.mcp).toEqual([
|
|
{
|
|
name: "docs",
|
|
directory: "/workspace",
|
|
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
|
|
},
|
|
])
|
|
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).toEqual({
|
|
sessionId: result.sessionId,
|
|
update: { sessionUpdate: "available_commands_update", availableCommands: [{ name: "review", description: "" }] },
|
|
})
|
|
})
|
|
|
|
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
|
await using acp = await startWire()
|
|
acp.server.catalog.models = [{ ...secondModel, variants: [{ id: "none" }, { id: "high" }] }]
|
|
await acp.initialize()
|
|
|
|
const created = await acp.newSession()
|
|
|
|
expect(currentValue(created, "effort")).toBe("default")
|
|
expect(acp.server.sessions.get(created.sessionId)?.model).toBeUndefined()
|
|
expect(acp.server.selections).toEqual([])
|
|
})
|
|
|
|
test("loads and forks with paginated replay while resume does 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" } })
|
|
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(history.map((message) => message.id))
|
|
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("lists server-backed pages for the requested cwd", async () => {
|
|
await using acp = await startWire()
|
|
Array.from({ length: 101 }, (_, index) =>
|
|
makeSession(`ses_${index}`, { time: { created: index, updated: 1_000 + index } }),
|
|
).forEach((session) => acp.server.sessions.set(session.id, session))
|
|
acp.server.sessions.set(
|
|
"ses_other",
|
|
makeSession("ses_other", { cwd: "/other", time: { created: 0, updated: 9_999 } }),
|
|
)
|
|
await acp.initialize()
|
|
|
|
const first = await acp.request("session/list", { cwd: "/workspace" })
|
|
const second = await acp.request("session/list", { cwd: "/workspace", cursor: first.nextCursor })
|
|
|
|
expect(first.sessions).toHaveLength(100)
|
|
expect(first.sessions[0]).toEqual({
|
|
sessionId: "ses_100",
|
|
cwd: "/workspace",
|
|
title: "Session ses_100",
|
|
updatedAt: new Date(1_100).toISOString(),
|
|
})
|
|
expect(first.nextCursor).toBeDefined()
|
|
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
|
|
expect(second.nextCursor).toBeUndefined()
|
|
})
|
|
|
|
test("cancel keeps an idle session attached while close detaches it", async () => {
|
|
await using acp = await startSession()
|
|
|
|
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
|
expect((await acp.prompt(acp.sessionId, "after cancel")).stopReason).toBe("end_turn")
|
|
|
|
expect(await acp.request("session/close", { sessionId: acp.sessionId })).toEqual({})
|
|
expect(await rpcError(acp.prompt(acp.sessionId, "after close"))).toMatchObject({
|
|
code: -32602,
|
|
data: { sessionId: acp.sessionId },
|
|
})
|
|
expect(await acp.request("session/close", { sessionId: "missing" })).toEqual({})
|
|
})
|
|
|
|
test("deletes sessions from backing and local storage", async () => {
|
|
await using acp = await startSession()
|
|
|
|
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
|
|
expect(acp.server.sessions.has(acp.sessionId)).toBe(false)
|
|
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
|
|
expect(
|
|
await rpcError(
|
|
acp.request("session/set_config_option", { sessionId: acp.sessionId, configId: "effort", value: "high" }),
|
|
),
|
|
).toMatchObject({ code: -32602, data: { sessionId: acp.sessionId } })
|
|
})
|
|
|
|
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.newSession("/workspace", [local])
|
|
|
|
const localConfig = (args: string[]) => ({
|
|
name: "tools",
|
|
directory: "/workspace",
|
|
config: { type: "local", command: ["bun", ...args], environment: { TOKEN: "x" } },
|
|
})
|
|
expect(acp.server.mcp).toHaveLength(4)
|
|
expect(acp.server.mcp.filter((item) => item.name === "tools")).toEqual([
|
|
localConfig(["server.ts"]),
|
|
localConfig(["changed.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 },
|
|
})
|
|
})
|
|
})
|