Compare commits

..
24 changed files with 2737 additions and 3228 deletions

No files matched your search

+1 -3
View File
@@ -114,8 +114,7 @@ const QUOTA_CODES = new Set([
"creditlimitexceeded",
])
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
// Z.ai's Responses API reports account and plan rejections mid-stream as `permission_denied`.
const AUTH_CODES = new Set(["authentication_error", "permission_error", "permission_denied", "api_key_invalid"])
const AUTH_CODES = new Set(["authentication_error", "permission_error", "api_key_invalid"])
const SERVER_CODES = new Set([
"api_error",
"internal_error",
@@ -129,7 +128,6 @@ const SERVER_CODES = new Set([
])
// `invalid_request` is the Vercel AI Gateway's code for an upstream request rejection.
const INVALID_REQUEST_CODES = new Set([
"model_not_found",
"invalid_prompt",
"invalid_request",
"invalid_request_error",
-13
View File
@@ -422,19 +422,6 @@ describe("provider error rawBody classification", () => {
expect(
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
).toBe("QuotaExceeded")
// Z.ai Responses stream rejections
expect(
classifyProviderFailure({
message: "Unknown Model, please check the model code.",
rawBody: '{"type":"response.failed","response":{"error":{"code":"model_not_found"}}}',
})._tag,
).toBe("InvalidRequest")
expect(
classifyProviderFailure({
message: "Your GLM Coding Plan package has expired and is temporarily unavailable.",
rawBody: '{"type":"response.failed","response":{"error":{"code":"permission_denied"}}}',
})._tag,
).toBe("Authentication")
})
})
+2
View File
@@ -972,6 +972,8 @@ export const dict = {
"session.header.open.ariaLabel": "Open in {{app}}",
"session.header.open.menu": "Open options",
"session.header.open.copyPath": "Copy path",
"session.project.copyRepositoryPath": "Copy local repository path",
"session.project.copyWorktreePath": "Copy worktree path",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
@@ -4,9 +4,10 @@ import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { Tooltip } from "@opencode/ui/tooltip"
import { getFilename } from "@opencode/util/path"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useNavigate } from "@solidjs/router"
import { createMemo, For, Show, type ParentProps } from "solid-js"
import { createEffect, createMemo, For, onCleanup, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
@@ -17,7 +18,7 @@ import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/s
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettingsSurface } from "@/settings/surface"
import { pathKey } from "@/workspaces/path-key"
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { showToast } from "@/shell/notifications/toast"
import { sessionTitle } from "./title"
@@ -50,8 +51,29 @@ export function SessionProjectMenu(props: {
projectTruncated: false,
pathTruncated: false,
pathFocused: false,
workspaceCopied: false,
workspaceCopyDismissed: false,
})
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
const workspaceDirectory = createMemo(() => {
const directory = props.directory
if (!directory) return ""
if (!props.workspace) return props.project?.worktree ?? directory
return props.project?.sandboxes?.find((item) => containsDirectory(item, directory)) ?? directory
})
const workspaceName = createMemo(() => {
if (!props.workspace) return language.t("session.new.workspace.local")
return getFilename(workspaceDirectory())
})
createEffect(() => {
if (!state.workspaceCopied) return
const dismiss = setTimeout(() => setState("workspaceCopyDismissed", true), 2000)
const reset = setTimeout(() => setState("workspaceCopied", false), 2100)
onCleanup(() => {
clearTimeout(dismiss)
clearTimeout(reset)
})
})
const canOpenPath = () =>
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
const openPath = () => {
@@ -71,6 +93,14 @@ export function SessionProjectMenu(props: {
project: current.worktree,
})
}
const copyWorkspaceDirectory = () => {
const directory = workspaceDirectory()
if (!directory) return
void (platform.writeClipboardText?.(directory) ?? navigator.clipboard.writeText(directory)).then(
() => setState({ workspaceCopied: true, workspaceCopyDismissed: false }),
() => showToast({ title: language.t("common.requestFailed") }),
)
}
return (
<Menu
@@ -79,7 +109,11 @@ export function SessionProjectMenu(props: {
shift={-10}
modal={false}
open={state.open}
onOpenChange={(open) => setState({ open, pathFocused: false })}
onOpenChange={(open) => {
setState({ open, pathFocused: false })
if (open) return
setState({ workspaceCopied: false, workspaceCopyDismissed: false })
}}
>
<Tooltip placement="bottom" value={<bdi>{projectName()}</bdi>} class="flex shrink-0">
<Menu.Trigger
@@ -136,6 +170,49 @@ export function SessionProjectMenu(props: {
</span>
</Menu.Item>
</Tooltip>
<Tooltip
placement="top"
gutter={2}
value={
state.workspaceCopied
? language.t("common.copied")
: language.t(
props.workspace ? "session.project.copyWorktreePath" : "session.project.copyRepositoryPath",
)
}
forceOpen={state.workspaceCopyDismissed ? false : state.workspaceCopied ? true : undefined}
getAnchorRect={(anchor) =>
anchor?.querySelector('[data-slot="session-project-copy-icon"]')?.getBoundingClientRect()
}
class="min-w-0"
>
<Menu.Item
class="session-project-link min-w-0 w-full"
closeOnSelect={false}
onSelect={copyWorkspaceDirectory}
onPointerLeave={() => setState({ workspaceCopied: false, workspaceCopyDismissed: false })}
>
<span class="session-project-link-content">
<Icon
name={props.workspace ? "outline-worktree" : "monitor"}
class="shrink-0 text-v2-icon-icon-muted"
/>
<bdi
data-slot="session-project-workspace"
class="min-w-0 truncate text-v2-text-text-muted"
>
{workspaceName()}
</bdi>
</span>
<span
data-slot="session-project-copy-icon"
class="session-project-link-open"
aria-hidden="true"
>
<Icon name={state.workspaceCopied ? "check" : "outline-copy"} />
</span>
</Menu.Item>
</Tooltip>
<Tooltip
placement="top"
gutter={2}
+223
View File
@@ -0,0 +1,223 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption, SessionNotification } from "@agentclientprotocol/sdk"
import { currentValue, requireSelectOption, selectValues } from "./select-options"
import {
buildAgent,
ephemeralEvent,
planAgent,
reviewCommand,
rpcError,
secondModel,
startSession,
startWire,
testModel,
type Wire,
} from "./wire-fixture"
describe("acp catalog and config options over the wire", () => {
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
await using acp = await startWire()
await acp.initialize()
const first = await Promise.all([acp.newSession("/workspace"), acp.newSession("/workspace")])
const other = await acp.newSession("/other")
expect(currentValue(first[0], "model")).toBe("test/test-model")
expect(currentValue(first[0], "mode")).toBe("build")
expect(
(["model", "default", "agent", "command"] as const).map((kind) =>
acp.server.catalogReads.filter((read) => read.kind === kind).map((read) => read.directory),
),
).toEqual(Array.from({ length: 4 }, () => ["/workspace", "/other"]))
expect(
Object.fromEntries([...acp.server.sessions.values()].map((session) => [session.id, session.location.directory])),
).toEqual({
[first[0].sessionId]: "/workspace",
[first[1].sessionId]: "/workspace",
[other.sessionId]: "/other",
})
await acp.until(() => acp.updates.filter((item) => commandNames(item)).length === 3, "commands for each session")
expect(acp.updates.map(commandNames)).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" }
await using acp = await startSession()
expect(currentValue(acp.session, "mode")).toBe("build")
expect(currentValue(acp.session, "model")).toBe("test/test-model")
const reads = agentReads(acp)
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/other" }))
acp.server.catalog.agents = [configured, buildAgent, planAgent]
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
const update = await acp.waitForUpdate((item) => 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 acp.waitForUpdate((item) => commandNames(item)?.length === 2)
expect(commandNames(commands)).toEqual(["review", "ship"])
expect(agentReads(acp)).toBe(reads + 1)
const second = await acp.newSession()
expect(currentValue(second, "mode")).toBe("copilot-build")
})
test("pushes config options on model.updated and commands on command.updated", async () => {
await using acp = await startWire()
acp.server.catalog.models = [testModel]
await acp.initialize()
const session = await acp.newSession()
expect(selectValues(session.configOptions, "model")).toEqual(["test/test-model"])
acp.server.catalog.models = [testModel, secondModel]
acp.server.send(ephemeralEvent("model.updated", {}))
const options = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
expect(
options.update.sessionUpdate === "config_option_update" && selectValues(options.update.configOptions, "model"),
).toEqual(["test/second-model", "test/test-model"])
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
expect(commands).toEqual({
sessionId: session.sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "ship", description: "Ship it" },
],
},
})
expect(acp.updates.filter((item) => item.update.sessionUpdate === "config_option_update")).toHaveLength(1)
})
test("reloads the catalog before rejecting a model or mode it has not seen", async () => {
const configured = { ...planAgent, id: "copilot-build", name: "copilot-build" }
await using acp = await startWire()
acp.server.catalog.models = [testModel]
await acp.initialize()
const session = await acp.newSession()
acp.server.catalog.models = [testModel, secondModel]
acp.server.catalog.agents = [buildAgent, planAgent, configured]
const model = await acp.request("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
const reads = agentReads(acp)
const missing = await rpcError(
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "mode", value: "missing" }),
)
expect(currentValue(model, "model")).toBe("test/second-model")
expect(acp.server.selections).toContainEqual({ sessionID: session.sessionId, agent: "copilot-build" })
expect(missing).toMatchObject({ code: -32602, data: { mode: "missing" } })
expect(agentReads(acp)).toBeGreaterThan(reads)
})
test.each(["empty", "missing the default"])(
"retries when the model list is %s but the default is ready",
async (initial) => {
await using acp = await startWire()
acp.server.catalog.models = initial === "empty" ? [] : [secondModel]
acp.server.catalog.defaultModel = testModel
await acp.initialize()
const created = acp.newSession()
await acp.until(() => modelReads(acp) === 1, "first model read")
acp.server.catalog.models = [testModel, secondModel]
const session = await created
const choices = selectValues(session.configOptions, "model")
expect(choices).toContain("test/second-model")
expect(choices).toContain("test/test-model")
expect(currentValue(session, "model")).toBe("test/test-model")
expect(modelReads(acp)).toBe(2)
},
)
test("does not cache a failed catalog load", async () => {
const failure = { pending: true }
await using acp = await startWire({
fetch(request) {
if (request.path !== "/api/model" || !failure.pending) return undefined
failure.pending = false
return Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
},
})
await acp.initialize()
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603 })
expect(acp.server.sessions.size).toBe(0)
const retried = await acp.newSession()
expect(acp.server.sessions.has(retried.sessionId)).toBe(true)
expect(modelReads(acp)).toBe(1)
})
test("switches model, effort, and mode against the warm catalog", async () => {
await using acp = await startSession()
const sessionId = acp.sessionId
const set = (configId: string, value: string) =>
acp.request("session/set_config_option", { sessionId, configId, value })
const selectedModel = await set("model", "test/second-model")
const selectedEffort = await set("effort", "medium")
const selectedMode = await set("mode", "plan")
await acp.request("session/set_mode", { 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(acp.server.selections).toEqual([
{ sessionID: sessionId, model: { providerID: "test", id: secondModel.id } },
{ sessionID: sessionId, model: { providerID: "test", id: secondModel.id, variant: "medium" } },
{ sessionID: sessionId, agent: "plan" },
{ sessionID: sessionId, agent: "build" },
])
expect(modelReads(acp)).toBe(1)
expect(await rpcError(set("effort", "maximum"))).toMatchObject({ code: -32602, data: { effort: "maximum" } })
expect(await rpcError(set("mode", "missing"))).toMatchObject({ code: -32602, data: { mode: "missing" } })
expect(await rpcError(set("missing", "value"))).toMatchObject({ code: -32602, data: { configId: "missing" } })
expect(await rpcError(set("model", "test/missing-model"))).toMatchObject({
code: -32602,
data: { modelId: "test/missing-model" },
})
})
test.todo(
"advertises the built-in compact command (https://github.com/anomalyco/opencode/issues/37229)",
async () => {
await using acp = await startSession()
const commands = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
expect(commandNames(commands)).toContain("compact")
},
)
})
function commandNames(item: SessionNotification) {
if (item.update.sessionUpdate !== "available_commands_update") return undefined
return item.update.availableCommands.map((command) => command.name)
}
function agentReads(acp: Wire) {
return acp.server.catalogReads.filter((read) => read.kind === "agent").length
}
function modelReads(acp: Wire) {
return acp.server.catalogReads.filter((read) => read.kind === "model").length
}
function modeOption(options: SessionConfigOption[]) {
const mode = requireSelectOption(options, "mode")
return { currentValue: mode.currentValue, options: selectValues(options, "mode") }
}
@@ -1,15 +1,7 @@
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import {
alternateValue,
createAcpFixture,
expectOk,
flattenSelectOptions,
initialize,
newSession,
requireSelectOption,
selectConfigOption,
} from "./subprocess"
import { alternateValue, flattenSelectOptions, requireSelectOption, selectConfigOption } from "./select-options"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
describe("acp config option subprocess", () => {
test('model option is listed with category "model"', async () => {
@@ -1,910 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { resolve } from "node:path"
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Fixture = ReturnType<typeof createSseFixture>
describe("acp event behavior", () => {
test("subscribes before admission and isolates sessions and input IDs", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_before",
ordinal: 0,
delta: "before admission",
}),
)
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_wrong_input",
ordinal: 0,
delta: "wrong input",
}),
)
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_b",
assistantMessageID: "msg_b",
ordinal: 0,
delta: "other session",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_a",
ordinal: 0,
delta: "accepted",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_a",
assistantMessageID: "msg_a",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_a",
inboxID: "input_a",
})
expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
"/api/event",
"/api/session/ses_a/prompt",
])
expect(updates).toEqual([
{
sessionId: "ses_a",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_a",
content: { type: "text", text: "accepted" },
},
},
])
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
const firstUpdate = Promise.withResolvers<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
const releaseSubmit = Promise.withResolvers<void>()
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
async onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id }))
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: "think-1",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: " continued",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
delta: "answer",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
delta: "think-2",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
await releaseSubmit.promise
},
})
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
if (updates.length === 1) {
firstUpdate.resolve()
await releaseUpdate.promise
}
if (updates.length === 4) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
const result = turn({ fixture, connection, sessionID: "ses_order", inboxID: "input_order" })
try {
await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
expect(updates).toHaveLength(1)
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
releaseUpdate.resolve()
await withTimeout(allUpdates.promise, "ordered updates did not finish")
expect(await Promise.race([result.then(() => "resolved"), Promise.resolve("pending")])).toBe("pending")
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
releaseSubmit.resolve()
const response = await withTimeout(result, "turn did not resolve after admission returned")
expect(
updates.map((item) => {
if (
item.update.sessionUpdate === "agent_message_chunk" ||
item.update.sessionUpdate === "agent_thought_chunk"
) {
return [
item.update.sessionUpdate,
item.update.messageId,
item.update.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
])
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
const replayed: SessionUpdateParams[] = []
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
{
id: "msg_order",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1 },
content: [
{ type: "reasoning", text: "think-1 continued" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "think-2" },
],
},
])
expect(replayed).toEqual([
{
sessionId: "ses_order",
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "msg_order:reasoning:0",
content: { type: "text", text: "think-1 continued" },
},
},
updates[2],
updates[3],
])
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
await result.catch(() => undefined)
await fixture.stop()
}
})
test("projects foreground child session updates onto the parent turn", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
...childSession("ses_child", "ses_parent", "Explore code"),
}),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
name: "read",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
input: { path: "/workspace/src/index.ts" },
executed: false,
}),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
metadata: {},
content: [{ type: "text", text: "source" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inboxID: "input_parent",
})
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
["ses_parent", "tool_call"],
["ses_parent", "tool_call_update"],
["ses_parent", "tool_call_update"],
])
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
"ses_child:call_read",
"ses_child:call_read",
"ses_child:call_read",
])
expect(updates[0]?.update).toMatchObject({
title: "Explore code: read",
_meta: {
"opencode/child-session": {
id: "ses_child",
parentID: "ses_parent",
depth: 1,
title: "Explore code",
},
},
})
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("continues child extension updates after the parent turn ends", async () => {
const updates: SessionUpdateParams[] = []
const childUpdates: ChildSessionUpdate[] = []
const completed = Promise.withResolvers<void>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_background",
...childSession("ses_background", "ses_parent", "Background research"),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inboxID: "input_parent",
childSessionUpdate: async (update) => {
childUpdates.push(update)
if (update.type === "status" && update.status === "completed") completed.resolve()
},
})
expect(response.stopReason).toBe("end_turn")
fixture.send(
durableEvent("session.created", {
sessionID: "ses_future",
...childSession("ses_future", "ses_parent", "Later turn child"),
}),
)
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
fixture.send(
durableEvent("session.tool.input.started", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
name: "shell",
}),
)
fixture.send(
durableEvent("session.tool.called", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
input: { command: "pwd" },
executed: false,
}),
)
fixture.send(
durableEvent("session.tool.success", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
metadata: { exit: 0 },
content: [{ type: "text", text: "/workspace" }],
executed: true,
}),
)
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
await withTimeout(completed.promise, "background child completion was not delivered")
expect(updates).toEqual([])
expect(
childUpdates.map((update) =>
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
),
).toEqual([
["status", "created"],
["status", "running"],
["update", "tool_call"],
["update", "tool_call_update"],
["update", "tool_call_update"],
["status", "completed"],
])
expect(childUpdates[2]).toMatchObject({
rootSessionId: "ses_parent",
childSessionId: "ses_background",
parentSessionId: "ses_parent",
depth: 1,
title: "Background research",
type: "update",
update: { toolCallId: "ses_background:call_shell" },
})
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
} finally {
await fixture.stop()
}
})
test("streams tool pending, progress, success, and failure updates", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
name: "shell",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
)
send(
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
metadata: { phase: 1 },
}),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
executed: true,
}),
)
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
name: "read",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
input: { path: "/workspace/missing.ts" },
executed: false,
}),
)
send(
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
metadata: { bytes: 0 },
}),
)
send(
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
executed: true,
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_tools",
inboxID: "input_tools",
})
expect(
updates.map((item) => [
item.update.sessionUpdate,
"status" in item.update ? item.update.status : undefined,
"toolCallId" in item.update ? item.update.toolCallId : undefined,
]),
).toEqual([
["tool_call", "pending", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "completed", "call_ok"],
["tool_call", "pending", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "failed", "call_fail"],
])
expect(updates[1]?.update).toMatchObject({
title: "printf done",
kind: "execute",
locations: [{ path: resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
})
expect(updates[2]?.update).not.toHaveProperty("content")
expect(updates[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[7]?.update).toMatchObject({
kind: "read",
locations: [{ path: "/workspace/missing.ts" }],
content: [
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
],
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
})
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("replays user, text, reasoning, and tool messages in order", async () => {
const updates: SessionUpdateParams[] = []
const messages = replayFixtureMessages()
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
await replayMessages(connection, "ses_replay", "/workspace", messages)
expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
"user_message_chunk",
"user_message_chunk",
"user_message_chunk",
"agent_message_chunk",
"agent_thought_chunk",
"tool_call",
"tool_call_update",
"tool_call",
"tool_call_update",
"tool_call",
"tool_call_update",
"tool_call",
])
expect(updates[1]?.update).toMatchObject({
content: {
type: "resource_link",
uri: "file:///workspace/note.md",
name: "note.md",
mimeType: "text/markdown",
},
})
expect(updates[2]?.update).toMatchObject({
content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
})
expect(updates[6]?.update).toMatchObject({
toolCallId: "call_done",
status: "completed",
content: [
{ type: "content", content: { type: "text", text: "done" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
],
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[8]?.update).toMatchObject({
toolCallId: "call_running",
status: "in_progress",
title: "pwd",
locations: [{ path: "/workspace" }],
})
expect(updates[10]?.update).toMatchObject({
toolCallId: "call_failed",
status: "failed",
content: [
{ type: "content", content: { type: "text", text: "partial" } },
{ type: "content", content: { type: "text", text: "failed hard" } },
],
})
})
test("continues replay after a session update callback rejects", async () => {
const attempts: Array<[string, string]> = []
const connection = {
sessionUpdate: async (params) => {
if (params.update.sessionUpdate !== "tool_call" && params.update.sessionUpdate !== "tool_call_update") return
attempts.push([params.update.toolCallId, params.update.sessionUpdate])
if (params.update.toolCallId === "call_first" && params.update.sessionUpdate === "tool_call_update") {
throw new Error("replay send failed")
}
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
await replayMessages(connection, "ses_replay_failure", "/workspace", [
replayToolMessage("call_first"),
replayToolMessage("call_after"),
])
expect(attempts).toEqual([
["call_first", "tool_call"],
["call_first", "tool_call_update"],
["call_after", "tool_call"],
["call_after", "tool_call_update"],
])
})
test("returns cancelled after an admitted turn is interrupted", async () => {
const submitted = Promise.withResolvers<void>()
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
return true
},
})
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
writeTextFile: false,
control,
submit: async (signal) => {
await fixture.client.session.prompt(
{ sessionID: "ses_cancel", id: "input_cancel", text: "cancel me" },
{ signal },
)
submitted.resolve()
},
})
try {
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
control.cancelled = true
control.admission.abort()
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
const response = await withTimeout(result, "cancelled turn did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
} finally {
await fixture.stop()
}
})
test("returns cancelled when admission is aborted before promotion", async () => {
const submitted = Promise.withResolvers<void>()
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ signal }) {
submitted.resolve()
return new Promise<void>((resolve) => {
if (signal.aborted) return resolve()
signal.addEventListener("abort", () => resolve(), { once: true })
})
},
})
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: "ses_cancel_admission",
cwd: "/workspace",
start: { type: "input", id: "input_cancel_admission" },
writeTextFile: false,
control,
submit: (signal) =>
fixture.client.session.prompt(
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
{ signal },
),
})
try {
await withTimeout(submitted.promise, "cancel test prompt was not submitted")
control.cancelled = true
control.admission.abort()
const response = await withTimeout(result, "pre-admission cancellation did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
} finally {
control.cancelled = true
control.admission.abort()
await result.catch(() => undefined)
await fixture.stop()
}
})
test("cancels unsupported session forms so execution can continue", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
send(
ephemeralEvent("form.created", {
form: {
id: "frm_question",
sessionID: "ses_form",
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", title: "Choice", type: "string" }],
},
}),
)
},
onFormCancel({ sessionID, formID, send }) {
send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
send(durableEvent("session.execution.succeeded", { sessionID }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection([]),
sessionID: "ses_form",
inboxID: "input_form",
})
expect(response.stopReason).toBe("end_turn")
expect(
fixture.requests.some(
(request) => request.method === "DELETE" && request.path === "/api/session/ses_form/form/frm_question",
),
).toBe(true)
} finally {
await fixture.stop()
}
})
})
function recordingConnection(updates: SessionUpdateParams[]) {
return {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
}
function turn(input: {
readonly fixture: Fixture
readonly connection: Connection
readonly sessionID: string
readonly inboxID: string
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
}) {
return streamTurn({
client: input.fixture.client,
connection: input.connection,
sessionID: input.sessionID,
cwd: "/workspace",
start: { type: "input", id: input.inboxID },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
})
}
function childSession(id: string, parentID: string, title: string) {
return {
slug: id,
projectID: "project",
location: { directory: "/workspace" },
parentID,
title,
version: "test",
}
}
function tokens() {
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
}
function replayFixtureMessages(): SessionMessageInfo[] {
return [
{
id: "msg_user",
type: "user",
text: "hello",
time: { created: 1 },
files: [
{
data: "",
mime: "text/markdown",
name: "note.md",
source: { type: "uri", uri: "file:///workspace/note.md" },
},
{
data: "aGVsbG8=",
mime: "text/plain",
name: "inline.txt",
source: { type: "inline" },
},
],
},
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 2, completed: 3 },
content: [
{ type: "text", text: "answer" },
{ type: "reasoning", text: "thinking" },
{
type: "tool",
id: "call_done",
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: "printf done" },
metadata: { exit: 0 },
content: [
{ type: "text", text: "done" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
],
},
},
{
type: "tool",
id: "call_running",
name: "shell",
time: { created: 2, ran: 2 },
state: {
status: "running",
input: { command: "pwd" },
metadata: {},
},
},
{
type: "tool",
id: "call_failed",
name: "read",
time: { created: 2, completed: 3 },
state: {
status: "error",
input: { path: "/workspace/missing.ts" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "partial" }],
error: { type: "tool.error", message: "failed hard" },
},
},
{
type: "tool",
id: "call_streaming",
name: "shell",
time: { created: 2 },
state: { status: "streaming", input: '{"command":' },
},
],
},
]
}
function replayToolMessage(id: string) {
return {
id: `msg_${id}`,
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1, completed: 2 },
content: [
{
type: "tool",
id,
name: "shell",
time: { created: 1, completed: 2 },
state: {
status: "completed",
input: { command: "printf done" },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
},
},
],
} satisfies SessionMessageInfo
}
-162
View File
@@ -1,162 +0,0 @@
import { expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode/client/promise"
import { streamTurn } from "../../src/acp/event"
test("acp prompt resolves after ordered turn updates", async () => {
const encoder = new TextEncoder()
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
send(controller, { id: "evt_connected", type: "server.connected", data: {} })
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
if (url.pathname === "/api/session/ses_test/prompt") {
const body: unknown = await request.json()
if (!body || typeof body !== "object") {
return new Response(null, { status: 400 })
}
const id = "id" in body ? body.id : undefined
if (typeof id !== "string") return new Response(null, { status: 400 })
queueMicrotask(() => {
if (!events) return
send(events, {
id: "evt_promoted",
created: 1,
type: "session.inbox.delivered",
data: { sessionID: "ses_test", inboxID: id },
})
send(events, {
id: "evt_text",
created: 2,
type: "session.text.delta",
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hello" },
})
send(events, {
id: "evt_step",
created: 3,
type: "session.step.ended",
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
send(events, {
id: "evt_done",
created: 4,
type: "session.execution.succeeded",
data: { sessionID: "ses_test" },
})
})
return Response.json({ data: {} })
}
if (url.pathname === "/api/session/ses_test/message/msg_assistant") {
return Response.json({
data: {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "hello" }],
finish: "stop",
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 4 },
},
})
}
return new Response(null, { status: 404 })
},
})
const client = OpenCode.make({ baseUrl: server.url.toString() })
try {
const id = "msg_prompt"
const response = await streamTurn({
client,
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
})
expect(updates).toEqual([
{
sessionId: "ses_test",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hello" },
},
},
])
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
} finally {
events?.close()
await server.stop(true)
}
function send(controller: ReadableStreamDefaultController<Uint8Array>, event: unknown) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
})
test("acp action resolves without prompt lifecycle events", async () => {
const encoder = new TextEncoder()
const server = Bun.serve({
port: 0,
fetch(request) {
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
try {
const response = await streamTurn({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id: "msg_action" },
writeTextFile: false,
action: true,
control: { cancelled: false, admission: new AbortController() },
submit: async () => {},
})
expect(response).toMatchObject({ stopReason: "end_turn" })
} finally {
await server.stop(true)
}
})
@@ -6,7 +6,8 @@ import type {
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption } from "./subprocess"
import { selectConfigOption } from "./select-options"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
@@ -98,4 +99,30 @@ describe("acp lifecycle subprocess", () => {
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}, 60_000)
// The private server is found with `pgrep`, which Windows lacks.
const todoOutsideWindows = process.platform === "win32" ? test.skip : test.todo
todoOutsideWindows(
"exits when the private server process dies (https://github.com/anomalyco/opencode/issues/51716)",
async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
await newSession(acp, fixture.home)
const servers = Bun.spawnSync(["pgrep", "-P", String(acp.pid)])
.stdout.toString()
.split("\n")
.filter(Boolean)
.map(Number)
expect(servers).toHaveLength(1)
process.kill(servers[0], "SIGKILL")
const timeout = Promise.withResolvers<"running">()
const timer = setTimeout(() => timeout.resolve("running"), 10_000)
const exited = await Promise.race([acp.exited, timeout.promise]).finally(() => clearTimeout(timer))
expect(exited).not.toBe("running")
},
60_000,
)
})
@@ -1,634 +0,0 @@
import { describe, expect, test } from "bun:test"
import type {
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
WriteTextFileRequest,
} from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { ACPConnection } from "../../src/acp/connection"
import { streamTurn } from "../../src/acp/event"
import { syncEditedFiles } from "../../src/acp/permission"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = SessionNotification
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => {
test("does not sync edits when writeTextFile was not advertised", async () => {
const writes: WriteTextFileRequest[] = []
await syncEditedFiles({
connection: {
writeTextFile: async (input) => {
writes.push(input)
return {}
},
},
writeTextFile: false,
sessionID: "ses_no_write",
cwd: "/workspace",
toolName: "edit",
toolInput: { filePath: "/workspace/file.ts" },
metadata: {},
})
expect(writes).toEqual([])
})
test("forwards allow-once and allow-always selections to the generated client", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_allow", inboxID: id }))
send(
permissionAsked("ses_allow", "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
}),
)
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return {
outcome: {
outcome: "selected",
optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always",
},
}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_allow", "input_allow")
expect(permissionRequests[0]).toMatchObject({
sessionId: "ses_allow",
toolCall: {
toolCallId: "call_once",
status: "pending",
title: "printf hello",
kind: "execute",
locations: [{ path: "/workspace" }],
rawInput: { command: "printf hello", cwd: "/workspace" },
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(permissionRequests[1]).toMatchObject({
sessionId: "ses_allow",
toolCall: {
toolCallId: "call_always",
status: "pending",
title: "/workspace/file.ts",
kind: "read",
locations: [{ path: "/workspace/file.ts" }],
rawInput: { path: "/workspace/file.ts" },
},
})
expect(permissionReplies(fixture)).toEqual([
["perm_once", "once"],
["perm_always", "always"],
])
} finally {
await fixture.stop()
}
})
test("preserves external directory permission context", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_external", inboxID: id }))
send(
permissionAsked("ses_external", "perm_external", {
action: "external_directory",
metadata: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_external" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_external", "input_external")
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "Create external directory",
locations: [{ path: "/tmp/outside" }],
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
})
} finally {
await fixture.stop()
}
})
test("routes foreground child permissions through the parent ACP session", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
slug: "ses_child",
projectID: "project",
location: { directory: "/workspace" },
parentID: "ses_parent",
title: "Review code",
version: "test",
}),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(
permissionAsked("ses_child", "perm_child", {
action: "read",
metadata: { path: "/workspace/child.ts" },
source: { type: "tool", messageID: "msg_child", id: "call_child" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_parent", "input_parent")
expect(permissionRequests).toHaveLength(1)
expect(permissionRequests[0]).toMatchObject({
sessionId: "ses_parent",
toolCall: {
toolCallId: "ses_child:call_child",
title: "Review code: /workspace/child.ts",
},
})
expect(fixture.requests).toContainEqual(
expect.objectContaining({
method: "POST",
path: "/api/session/ses_child/permission/perm_child/reply",
}),
)
} finally {
await fixture.stop()
}
})
test("previews edits during approval and syncs the completed file", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
const file = path.join(cwd, "file.ts")
await fs.writeFile(file, "before")
const permissionRequests: RequestPermissionRequest[] = []
const writes: WriteTextFileRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
id: "call_edit",
name: "edit",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
id: "call_edit",
input: { path: "file.ts", oldString: "before", newString: "after" },
executed: false,
}),
)
send(
permissionAsked("ses_edit", "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
)
},
async onPermissionReply({ send }) {
await fs.writeFile(file, "after")
send(
durableEvent("session.tool.success", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
id: "call_edit",
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_edit" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
writeTextFile: async (request) => {
writes.push(request)
return {}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_edit", "input_edit", cwd)
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "file.ts",
kind: "edit",
locations: [{ path: "file.ts" }],
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
})
expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
} finally {
await fixture.stop()
await fs.rm(cwd, { recursive: true, force: true })
}
})
test("previews and syncs each file in a patch", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
await Promise.all([
fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
fs.writeFile(path.join(cwd, "second.ts"), "alpha\n"),
])
const patchText = [
"*** Begin Patch",
"*** Update File: first.ts",
"@@",
"-one",
"+two",
"*** Update File: second.ts",
"@@",
"-alpha",
"+beta",
"*** End Patch",
].join("\n")
const permissionRequests: RequestPermissionRequest[] = []
const writes: WriteTextFileRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
id: "call_patch",
name: "patch",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
id: "call_patch",
input: { patchText },
executed: false,
}),
)
send(
permissionAsked("ses_patch", "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
)
},
async onPermissionReply({ send }) {
await Promise.all([
fs.writeFile(path.join(cwd, "first.ts"), "two\n"),
fs.writeFile(path.join(cwd, "second.ts"), "beta\n"),
])
send(
durableEvent("session.tool.success", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
id: "call_patch",
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_patch" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
writeTextFile: async (request) => {
writes.push(request)
return {}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_patch", "input_patch", cwd)
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "2 files",
kind: "edit",
locations: [{ path: "first.ts" }, { path: "second.ts" }],
content: [
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
],
})
expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
{ sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
{ sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
])
} finally {
await fixture.stop()
await fs.rm(cwd, { recursive: true, force: true })
}
})
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_reject", inboxID: id }))
send(permissionAsked("ses_reject", "perm_selected_reject"))
send(permissionAsked("ses_reject", "perm_cancelled"))
send(permissionAsked("ses_reject", "perm_failed"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_reject" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request): Promise<RequestPermissionResponse> => {
if (request.toolCall.toolCallId === "perm_selected_reject") {
return { outcome: { outcome: "selected", optionId: "reject" } }
}
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
throw new Error("client permission UI failed")
},
} satisfies Connection
try {
const response = await startTurn(fixture, connection, "ses_reject", "input_reject")
expect(response).toMatchObject({ stopReason: "end_turn" })
expect(permissionReplies(fixture)).toEqual([
["perm_selected_reject", "reject"],
["perm_cancelled", "reject"],
["perm_failed", "reject"],
])
} finally {
await fixture.stop()
}
})
test("serializes permission requests and replies within one session", async () => {
const firstRequested = Promise.withResolvers<void>()
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_serial", inboxID: id }))
send(permissionAsked("ses_serial", "perm_1"))
send(permissionAsked("ses_serial", "perm_2"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
if (request.toolCall.toolCallId === "perm_1") {
firstRequested.resolve()
return releaseFirst.promise
}
return { outcome: { outcome: "selected", optionId: "always" } } as const
},
} satisfies Connection
const result = startTurn(fixture, connection, "ses_serial", "input_serial")
try {
await withTimeout(firstRequested.promise, "first permission was not requested")
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
expect(permissionReplies(fixture)).toEqual([])
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
await withTimeout(result, "serialized permission turn did not finish")
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
expect(permissionReplies(fixture)).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
} finally {
releaseFirst.resolve({ outcome: { outcome: "cancelled" } })
await result.catch(() => undefined)
await fixture.stop()
}
})
test("does not let one session's blocked permission stall another session", async () => {
const blockedRequested = Promise.withResolvers<void>()
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
const promptIDs = new Map<string, string>()
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ sessionID, id, send }) {
promptIDs.set(sessionID, id)
if (promptIDs.size !== 2) return
const blockedID = promptIDs.get("ses_blocked")
const freeID = promptIDs.get("ses_free")
if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
send(durableEvent("session.inbox.delivered", { sessionID: "ses_blocked", inboxID: blockedID }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_free", inboxID: freeID }))
send(permissionAsked("ses_blocked", "perm_blocked"))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_free",
assistantMessageID: "msg_free",
ordinal: 0,
delta: "session B continued",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_free",
assistantMessageID: "msg_free",
finish: "stop",
cost: 0,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_free" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_blocked" }))
},
})
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => {
blockedRequested.resolve()
return releaseBlocked.promise
},
} satisfies Connection
const blocked = startTurn(fixture, connection, "ses_blocked", "input_blocked")
const free = startTurn(fixture, connection, "ses_free", "input_free")
try {
await withTimeout(blockedRequested.promise, "blocked permission was not requested")
const response = await withTimeout(free, "free session was stalled by another session's permission")
expect(response).toMatchObject({ stopReason: "end_turn" })
expect(updates).toContainEqual({
sessionId: "ses_free",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_free",
content: { type: "text", text: "session B continued" },
},
})
expect(permissionReplies(fixture)).toEqual([])
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
await withTimeout(blocked, "blocked session did not resume after permission selection")
expect(permissionReplies(fixture)).toEqual([["perm_blocked", "once"]])
} finally {
releaseBlocked.resolve({ outcome: { outcome: "cancelled" } })
await Promise.all([blocked.catch(() => undefined), free.catch(() => undefined)])
await fixture.stop()
}
})
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
const requested = Promise.withResolvers<AbortSignal | undefined>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
send(permissionAsked("ses_cancel", "perm_cancel"))
},
onPermissionReply({ send }) {
send(durableEvent("session.execution.interrupted", { sessionID: "ses_cancel", reason: "user" }))
},
})
const connection = {
sessionUpdate: async () => {},
// Behaves like a client answering the agent's `$/cancel_request` with a cancelled outcome.
requestPermission: (_request, options) =>
new Promise<RequestPermissionResponse>((resolve) => {
options?.cancellationSignal?.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), {
once: true,
})
requested.resolve(options?.cancellationSignal)
}),
} satisfies Connection
const control = { cancelled: false, admission: new AbortController() }
const result = streamTurn({
client: fixture.client,
connection,
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
writeTextFile: false,
control,
submit: (signal) =>
fixture.client.session.prompt({ sessionID: "ses_cancel", id: "input_cancel", text: "hello" }, { signal }),
})
try {
const signal = await withTimeout(requested.promise, "permission was not requested")
expect(signal?.aborted).toBe(false)
control.cancelled = true
control.admission.abort()
expect(await withTimeout(result, "cancelled turn did not finish")).toMatchObject({ stopReason: "cancelled" })
expect(permissionReplies(fixture)).toEqual([["perm_cancel", "reject"]])
} finally {
await result.catch(() => undefined)
await fixture.stop()
}
})
})
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
return streamTurn({
client: fixture.client,
connection,
sessionID,
cwd,
start: { type: "input", id: inboxID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
})
}
function permissionAsked(
sessionID: string,
id: string,
input: {
readonly action?: string
readonly metadata?: Record<string, unknown>
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {},
) {
return ephemeralEvent("permission.asked", {
id,
sessionID,
action: input.action ?? "shell",
resources: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}),
})
}
function permissionReplies(fixture: Fixture) {
return fixture.requests.flatMap((request): Array<[string, string]> => {
const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
if (!match?.[1] || !request.body || typeof request.body !== "object") return []
const reply = "decision" in request.body ? request.body.decision : undefined
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
})
}
+387
View File
@@ -0,0 +1,387 @@
import { describe, expect, test } from "bun:test"
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import path from "node:path"
import { tmpdir } from "../fixture/tmpdir"
import {
childCreated,
delivered,
durableEvent,
interrupted,
permissionAsked,
startSession,
startWire,
stepEnded,
succeeded,
textDelta,
toolCalled,
toolStarted,
toolSucceeded,
turn,
type Wire,
} from "./wire-fixture"
const allowOnce = () => ({ outcome: { outcome: "selected", optionId: "once" } }) as const
describe("acp permissions over the wire", () => {
test("forwards allow-once and allow-always selections to the server", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
}),
permissionAsked(sessionID, "perm_always", {
action: "read",
metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
}),
),
permission: (request) => ({
outcome: { outcome: "selected", optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always" },
}),
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions[0]).toMatchObject({
sessionId: acp.sessionId,
toolCall: {
toolCallId: "call_once",
status: "pending",
title: "printf hello",
kind: "execute",
locations: [{ path: "/workspace" }],
rawInput: { command: "printf hello", cwd: "/workspace" },
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(acp.permissions[1]).toMatchObject({
sessionId: acp.sessionId,
toolCall: {
toolCallId: "call_always",
status: "pending",
title: "/workspace/file.ts",
kind: "read",
locations: [{ path: "/workspace/file.ts" }],
rawInput: { path: "/workspace/file.ts" },
},
})
expect(decisions(acp)).toEqual([
["perm_once", "once"],
["perm_always", "always"],
])
})
test("preserves external directory permission context", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_external", {
action: "external_directory",
metadata: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
}),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "Create external directory",
locations: [{ path: "/tmp/outside" }],
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
})
})
test("routes foreground child permissions through the parent ACP session", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Review code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
permissionAsked("ses_child", "perm_child", {
action: "read",
metadata: { path: "/workspace/child.ts" },
source: { type: "tool", messageID: "msg_child", id: "call_child" },
}),
succeeded("ses_child"),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions).toHaveLength(1)
expect(acp.permissions[0]).toMatchObject({
sessionId: acp.sessionId,
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts" },
})
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
})
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_selected_reject"),
permissionAsked(sessionID, "perm_cancelled"),
permissionAsked(sessionID, "perm_failed"),
),
permission(request) {
if (request.toolCall.toolCallId === "perm_selected_reject") {
return { outcome: { outcome: "selected", optionId: "reject" } }
}
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
throw new Error("client permission UI failed")
},
})
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(decisions(acp)).toEqual([
["perm_selected_reject", "reject"],
["perm_cancelled", "reject"],
["perm_failed", "reject"],
])
})
test("serializes permission requests and replies within one session", async () => {
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, permissionAsked(sessionID, "perm_1"), permissionAsked(sessionID, "perm_2")),
permission: (request) =>
request.toolCall.toolCallId === "perm_1"
? releaseFirst.promise
: { outcome: { outcome: "selected", optionId: "always" } },
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.permissions.length === 1, "first permission")
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
expect(acp.server.replies).toEqual([])
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
await prompt
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
expect(decisions(acp)).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
})
test("does not let one session's blocked permission stall another session", async () => {
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
await using acp = await startWire({ onPrompt: () => undefined, permission: () => releaseBlocked.promise })
await acp.initialize()
const blockedSession = await acp.newSession()
const freeSession = await acp.newSession()
const blocked = acp.prompt(blockedSession.sessionId, "hello")
const free = acp.prompt(freeSession.sessionId, "hello")
const [blockedPrompt, freePrompt] = await acp.until(
() => acp.server.prompts.length === 2 && acp.server.prompts,
"both prompt submissions",
)
acp.server.send(
delivered(blockedPrompt.sessionID, blockedPrompt.id),
delivered(freePrompt.sessionID, freePrompt.id),
permissionAsked(blockedPrompt.sessionID, "perm_blocked"),
textDelta(freePrompt.sessionID, "msg_free", "session B continued"),
stepEnded(freePrompt.sessionID, "msg_free"),
succeeded(freePrompt.sessionID),
succeeded(blockedPrompt.sessionID),
)
await acp.until(() => acp.permissions.length === 1, "blocked permission")
expect(await free).toMatchObject({ stopReason: "end_turn" })
expect(acp.updates).toContainEqual({
sessionId: freePrompt.sessionID,
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_free",
content: { type: "text", text: "session B continued" },
},
})
expect(acp.server.replies).toEqual([])
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
expect(await blocked).toMatchObject({ stopReason: "end_turn" })
expect(decisions(acp)).toEqual([["perm_blocked", "once"]])
})
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), permissionAsked(sessionID, "perm_cancel")],
onPermissionReply: ({ sessionID }) => [interrupted(sessionID)],
permission: (_request, signal) =>
new Promise((resolve) => {
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
}),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.permissions.length === 1, "permission request")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
expect(decisions(acp)).toEqual([["perm_cancel", "reject"]])
const asked = acp.received.find(
(message): message is AnyRequest =>
"method" in message && "id" in message && message.method === "session/request_permission",
)
expect(acp.received).toContainEqual({
jsonrpc: "2.0",
method: "$/cancel_request",
params: { requestId: asked?.id },
})
})
})
describe("acp edit previews and client file sync over the wire", () => {
test("previews edits during approval and syncs the completed file", async () => {
await using dir = await tmpdir()
const file = path.join(dir.path, "file.ts")
await fs.writeFile(file, "before")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "before", newString: "after" }),
permissionAsked(sessionID, "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
],
onPermissionReply: async ({ sessionID }) => {
await fs.writeFile(file, "after")
return [
toolSucceeded(sessionID, "call_edit", { files: [{ file: "file.ts" }], replacements: 1 }, "edited"),
succeeded(sessionID),
]
},
permission: allowOnce,
})
await acp.initialize({ writeTextFile: true })
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "file.ts",
kind: "edit",
locations: [{ path: "file.ts" }],
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
})
expect(acp.writes).toEqual([{ sessionId: session.sessionId, path: file, content: "after" }])
})
test("previews and syncs each file in a patch", async () => {
await using dir = await tmpdir()
await Promise.all([
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
fs.writeFile(path.join(dir.path, "second.ts"), "alpha\n"),
])
const patchText = [
"*** Begin Patch",
"*** Update File: first.ts",
"@@",
"-one",
"+two",
"*** Update File: second.ts",
"@@",
"-alpha",
"+beta",
"*** End Patch",
].join("\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
permissionAsked(sessionID, "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
],
onPermissionReply: async ({ sessionID }) => {
await Promise.all([
fs.writeFile(path.join(dir.path, "first.ts"), "two\n"),
fs.writeFile(path.join(dir.path, "second.ts"), "beta\n"),
])
return [
toolSucceeded(sessionID, "call_patch", { files: [{ file: "first.ts" }, { file: "second.ts" }] }, "patched"),
succeeded(sessionID),
]
},
permission: allowOnce,
})
await acp.initialize({ writeTextFile: true })
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "2 files",
kind: "edit",
locations: [{ path: "first.ts" }, { path: "second.ts" }],
content: [
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
],
})
expect(acp.writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
{ sessionId: session.sessionId, path: path.join(dir.path, "first.ts"), content: "two\n" },
{ sessionId: session.sessionId, path: path.join(dir.path, "second.ts"), content: "beta\n" },
])
})
test("does not sync edits when the client did not advertise writeTextFile", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "file.ts"), "after")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { filePath: path.join(dir.path, "file.ts") }),
toolSucceeded(sessionID, "call_edit", {}, "edited"),
),
})
await acp.initialize()
const session = await acp.newSession(dir.path)
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(acp.writes).toEqual([])
})
})
function decisions(acp: Wire) {
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
}
+402
View File
@@ -0,0 +1,402 @@
import { describe, expect, test } from "bun:test"
import type { StopReason } from "@agentclientprotocol/sdk"
import type { OpenCodeEvent } from "@opencode/client/promise"
import {
assistantMessage,
delivered,
durableEvent,
failed,
interrupted,
makeSession,
rpcError,
startSession,
stepEnded,
succeeded,
textDelta,
tokens,
turn,
type Wire,
type WireOptions,
} from "./wire-fixture"
// A "hold" prompt is admitted and starts streaming, then only finishes when interrupted.
const held = {
onPrompt: ({ sessionID, id, text }) =>
text === "hold" ? [delivered(sessionID, id), textDelta(sessionID, "msg_held", "working")] : turn(sessionID, id),
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
} satisfies WireOptions
describe("acp prompt turns over the wire", () => {
test("streams an admitted turn and resolves with usage after its terminal event", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, textDelta(sessionID, "msg_assistant", "hello"), stepEnded(sessionID, "msg_assistant")),
})
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
const response = await acp.prompt(acp.sessionId, "hi")
expect(response).toEqual({
stopReason: "end_turn",
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
_meta: {},
})
expect(acp.updates.filter((item) => item.update.sessionUpdate === "agent_message_chunk")).toEqual([
{
sessionId: acp.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hello" },
},
},
])
expect(acp.server.submissions).toEqual([
{
kind: "prompt",
sessionID: acp.sessionId,
id: expect.stringMatching(/^msg_/),
text: "hi",
files: [],
delivery: "steer",
},
])
})
test("routes slash commands and compact through their session endpoints", async () => {
await using acp = await startSession()
const command = await acp.prompt(acp.sessionId, "/review now")
const compact = await acp.prompt(acp.sessionId, "/compact")
expect([command.stopReason, compact.stopReason]).toEqual(["end_turn", "end_turn"])
expect(acp.server.submissions).toEqual([
{ kind: "command", sessionID: acp.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
{ kind: "compact", sessionID: acp.sessionId, id: expect.stringMatching(/^msg_/) },
])
})
test("submits assistant-only context as synthetic input before the visible prompt", async () => {
await using acp = await startSession()
await acp.prompt(acp.sessionId, [
{ type: "text", text: "visible" },
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
{ type: "resource_link", uri: "file:///workspace/README.md", name: "README.md", mimeType: "text/markdown" },
])
expect(acp.server.submissions).toEqual([
{
kind: "synthetic",
sessionID: acp.sessionId,
text: "hidden context",
description: "ACP embedded context",
delivery: "steer",
resume: false,
},
expect.objectContaining({
kind: "prompt",
text: "visible",
files: [{ uri: "file:///workspace/README.md", name: "README.md" }],
}),
])
})
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
const assistantTokens = { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } }
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, stepEnded(sessionID, "msg_assistant", { tokens: assistantTokens })),
})
await acp.request("session/set_config_option", {
sessionId: acp.sessionId,
configId: "model",
value: "test/second-model",
})
acp.server.sessions.set(acp.sessionId, makeSession(acp.sessionId, { cost: 3.5 }))
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant", { tokens: assistantTokens })])
const response = await acp.prompt(acp.sessionId, "hello")
expect(response).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
},
_meta: {},
})
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
sessionId: acp.sessionId,
update: { sessionUpdate: "usage_update", used: 171, size: 200_000, cost: { amount: 3.5, currency: "USD" } },
})
})
test("completes the prompt when reads after admission fail", async () => {
const reads = { failing: false }
await using acp = await startSession({
fetch: (request) => (reads.failing && request.method === "GET" ? new Response(null, { status: 500 }) : undefined),
onPrompt: ({ sessionID, id }) => {
reads.failing = true
return turn(sessionID, id, stepEnded(sessionID, "msg_assistant"))
},
})
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
})
test.each<{ name: string; stopReason: StopReason; events: (sessionID: string) => OpenCodeEvent[] }>([
{ name: "a normal stop", stopReason: "end_turn", events: (id) => [stepEnded(id, "msg"), succeeded(id)] },
{
name: "a length-limited step",
stopReason: "max_tokens",
events: (id) => [stepEnded(id, "msg", { finish: "length" }), succeeded(id)],
},
{
name: "a content-filtered step",
stopReason: "refusal",
events: (id) => [stepEnded(id, "msg", { finish: "content-filter" }), succeeded(id)],
},
{
name: "a content-filter failure",
stopReason: "refusal",
events: (id) => [failed(id, { type: "provider.content-filter", message: "blocked" })],
},
{
name: "a server-side interruption",
stopReason: "cancelled",
events: (id) => [durableEvent("session.execution.interrupted", { sessionID: id, reason: "shutdown" })],
},
])("maps $name to $stopReason", async (input) => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...input.events(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe(input.stopReason)
})
test("maps provider auth failures to auth required", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
failed(sessionID, { type: "provider.auth", message: "missing key" }),
],
})
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
})
test("maps an assistant message auth error to auth required", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, textDelta(sessionID, "msg_auth", "partial"), stepEnded(sessionID, "msg_auth")),
})
acp.server.messages.set(acp.sessionId, [
assistantMessage("msg_auth", { error: { type: "provider.auth", message: "expired" } }),
])
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
})
test("surfaces other execution failures as internal errors with the failure message", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
failed(sessionID, { type: "provider.rate-limit", message: "slow down" }),
],
})
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({
code: -32603,
message: expect.stringContaining("slow down"),
})
})
test("reports provider retries while pending and clears them when the next step starts", async () => {
const at = Date.UTC(2026, 0, 1)
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
retryScheduled(sessionID, 2, at),
durableEvent("session.step.started", {
sessionID,
assistantMessageID: "msg_retry",
agent: "build",
model: { providerID: "test", id: "test-model" },
started: at,
}),
stepEnded(sessionID, "msg_retry"),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(acp.updates.filter((item) => item.update.sessionUpdate === "session_info_update")).toEqual([
{
sessionId: acp.sessionId,
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": retryMeta(2, at) } },
},
{
sessionId: acp.sessionId,
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": null } },
},
])
expect(response._meta).toEqual({})
})
test("reports the pending retry on a turn cancelled during backoff", async () => {
const at = Date.UTC(2026, 0, 1)
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), retryScheduled(sessionID, 1, at)],
onInterrupt: held.onInterrupt,
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: { "opencode/retry": retryMeta(1, at) } })
})
test("session/cancel before admission aborts the submission and returns cancelled", async () => {
const aborted = Promise.withResolvers<void>()
await using acp = await startSession({
onPrompt: ({ signal }) =>
new Promise<void>((resolve) => {
signal.addEventListener(
"abort",
() => {
aborted.resolve()
resolve()
},
{ once: true },
)
}),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.server.submissions.length === 1, "prompt submission")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
await aborted.promise
expect(acp.server.interrupts).toContain(acp.sessionId)
})
test("session/cancel mid-turn interrupts the session once, returns cancelled, and keeps it usable", async () => {
await using acp = await startSession(held)
const prompt = acp.prompt(acp.sessionId, "hold")
await admitted(acp, acp.sessionId)
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
expect(acp.server.interrupts).toEqual([acp.sessionId])
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
})
test("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
await using acp = await startSession(held)
const controller = new AbortController()
const prompt = acp.prompt(acp.sessionId, "hold", controller.signal)
await admitted(acp, acp.sessionId)
controller.abort()
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
expect(acp.server.interrupts).toEqual([acp.sessionId])
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
})
test("session/close settles the active turn before responding and detaches only that session", async () => {
await using acp = await startSession(held)
const other = await acp.newSession()
const order: string[] = []
const prompt = acp.prompt(acp.sessionId, "hold").then((response) => {
order.push("prompt")
return response
})
await admitted(acp, acp.sessionId)
const close = await acp.request("session/close", { sessionId: acp.sessionId }).then((response) => {
order.push("close")
return response
})
expect(close).toEqual({})
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
expect(order).toEqual(["prompt", "close"])
expect(acp.server.interrupts).toEqual([acp.sessionId])
expect(await rpcError(acp.prompt(acp.sessionId, "again"))).toMatchObject({
code: -32602,
data: { sessionId: acp.sessionId },
})
expect((await acp.prompt(other.sessionId, "still here")).stopReason).toBe("end_turn")
})
test("rejects a second prompt while the session already has an active turn", async () => {
await using acp = await startSession(held)
const first = acp.prompt(acp.sessionId, "hold")
await acp.until(() => acp.server.submissions.length === 1, "first prompt")
expect(await rpcError(acp.prompt(acp.sessionId, "second"))).toMatchObject({ code: -32603 })
expect(acp.server.submissions).toHaveLength(1)
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await first).stopReason).toBe("cancelled")
})
test.todo(
"reports usage summed across every step of the turn (https://github.com/anomalyco/opencode/issues/41660)",
async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
),
})
acp.server.messages.set(acp.sessionId, [
assistantMessage("msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
])
const response = await acp.prompt(acp.sessionId, "hello")
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
},
)
})
// The server answered admission before streaming the chunk, and this request round-trips through the server after it,
// so the agent has observed admission before the test cancels.
async function admitted(acp: Wire, sessionId: string) {
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
await acp.request("session/set_mode", { sessionId, modeId: "build" })
}
function retryScheduled(sessionID: string, attempt: number, at: number) {
return durableEvent("session.retry.scheduled", {
sessionID,
assistantMessageID: "msg_retry",
attempt,
at,
error: { type: "provider.rate-limit", message: "rate limited" },
})
}
function retryMeta(attempt: number, at: number) {
return {
attempt,
nextRetryAt: new Date(at).toISOString(),
error: { type: "provider.rate-limit", message: "rate limited" },
}
}
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
describe("acp session replay over the wire", () => {
test("replays user, text, reasoning, and tool messages in order on session/load", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_replay", makeSession("ses_replay"))
acp.server.messages.set("ses_replay", replayFixtureMessages())
await acp.initialize()
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_replay", mcpServers: [] })
const updates = acp.updates.filter((item) => item.update.sessionUpdate !== "available_commands_update")
expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
"user_message_chunk",
"user_message_chunk",
"user_message_chunk",
"agent_message_chunk",
"agent_thought_chunk",
"tool_call",
"tool_call_update",
"tool_call",
"tool_call_update",
"tool_call",
"tool_call_update",
"tool_call",
])
expect(updates[1]?.update).toMatchObject({
content: { type: "resource_link", uri: "file:///workspace/note.md", name: "note.md", mimeType: "text/markdown" },
})
expect(updates[2]?.update).toMatchObject({
content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
})
expect(updates[4]?.update).toMatchObject({ messageId: "msg_assistant:reasoning:0" })
expect(updates[6]?.update).toMatchObject({
toolCallId: "call_done",
status: "completed",
content: [
{ type: "content", content: { type: "text", text: "done" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
],
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[8]?.update).toMatchObject({
toolCallId: "call_running",
status: "in_progress",
title: "pwd",
locations: [{ path: "/workspace" }],
})
expect(updates[10]?.update).toMatchObject({
toolCallId: "call_failed",
status: "failed",
content: [
{ type: "content", content: { type: "text", text: "partial" } },
{ type: "content", content: { type: "text", text: "failed hard" } },
],
})
expect(updates[11]?.update).toMatchObject({ toolCallId: "call_streaming", status: "pending", rawInput: {} })
})
test("continues replay after one message fails to translate", async () => {
await using acp = await startWire({
fetch(request) {
if (request.path !== "/api/session/ses_replay_failure/message") return undefined
return Response.json({
data: [
replayToolMessage("call_first", { status: "error", input: {}, metadata: {} }),
replayToolMessage("call_after", {
status: "completed",
input: { command: "printf done" },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
}),
],
cursor: {},
})
},
})
acp.server.sessions.set("ses_replay_failure", makeSession("ses_replay_failure"))
await acp.initialize()
const loaded = await acp.request("session/load", {
cwd: "/workspace",
sessionId: "ses_replay_failure",
mcpServers: [],
})
expect(loaded.configOptions).toBeDefined()
expect(
acp.updates.flatMap((item) =>
item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
? [[item.update.toolCallId, item.update.sessionUpdate]]
: [],
),
).toEqual([
["call_first", "tool_call"],
["call_after", "tool_call"],
["call_after", "tool_call_update"],
])
})
})
function replayToolMessage(id: string, state: Record<string, unknown>) {
return {
...assistantMessage(`msg_${id}`),
content: [{ type: "tool", id, name: "shell", time: { created: 1, completed: 2 }, state }],
}
}
function replayFixtureMessages(): SessionMessageInfo[] {
return [
{
id: "msg_user",
type: "user",
text: "hello",
time: { created: 1 },
files: [
{ data: "", mime: "text/markdown", name: "note.md", source: { type: "uri", uri: "file:///workspace/note.md" } },
{ data: "aGVsbG8=", mime: "text/plain", name: "inline.txt", source: { type: "inline" } },
],
},
assistantMessage("msg_assistant", {
time: { created: 2, completed: 3 },
content: [
{ type: "text", text: "answer" },
{ type: "reasoning", text: "thinking" },
{
type: "tool",
id: "call_done",
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: "printf done" },
metadata: { exit: 0 },
content: [
{ type: "text", text: "done" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
],
},
},
{
type: "tool",
id: "call_running",
name: "shell",
time: { created: 2, ran: 2 },
state: { status: "running", input: { command: "pwd" }, metadata: {} },
},
{
type: "tool",
id: "call_failed",
name: "read",
time: { created: 2, completed: 3 },
state: {
status: "error",
input: { path: "/workspace/missing.ts" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "partial" }],
error: { type: "tool.error", message: "failed hard" },
},
},
{
type: "tool",
id: "call_streaming",
name: "shell",
time: { created: 2 },
state: { status: "streaming", input: '{"command":' },
},
],
}),
]
}
+34
View File
@@ -0,0 +1,34 @@
import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"
type SelectOption = Extract<SessionConfigOption, { type: "select" }>
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
return options?.find((option): option is SelectOption => option.id === id && option.type === "select")
}
export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
if (!option) throw new Error(`Missing ACP config option: ${id}`)
return option
}
export function flattenSelectOptions(option: SelectOption) {
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
}
export function selectValues(options: SessionConfigOption[] | null | undefined, id: string) {
return flattenSelectOptions(requireSelectOption(options, id)).map((option) => option.value)
}
export function alternateValue(option: SelectOption) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
return value
}
export function currentValue(
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
id: string,
) {
return result?.configOptions?.find((option) => option.id === id)?.currentValue
}
@@ -1,419 +0,0 @@
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("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" }],
}
let created = 0
const mcp = "/api/experimental/mcp/"
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" && request.path.startsWith(mcp)) {
return new Response(null, { status: 204 })
}
return undefined
},
})
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, 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" && request.path.startsWith(mcp))
expect(adds).toHaveLength(4)
expect(adds.filter((request) => request.path === `${mcp}tools`).map((request) => request.body)).toEqual([
{
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 === `${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([
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
])
})
})
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
}
-193
View File
@@ -1,193 +0,0 @@
import type { SessionNotification } from "@agentclientprotocol/sdk"
import {
OpenCode,
type AgentInfo,
type CommandInfo,
type ModelInfo,
type ModelRef,
type SessionInfo,
type TokenUsageInfo,
} from "@opencode/client/promise"
import { ACPService } from "../../src/acp/service"
export type FixtureRequest = {
readonly method: string
readonly path: string
readonly query: Record<string, string>
readonly body: unknown
}
export type FixtureContext = {
readonly requests: FixtureRequest[]
send(event: unknown): void
}
type FixtureHandler = (
request: FixtureRequest,
context: FixtureContext,
) => Response | undefined | Promise<Response | undefined>
type FixtureOptions = {
readonly fetch?: FixtureHandler
readonly models?: readonly ModelInfo[]
readonly defaultModel?: ModelInfo
readonly agents?: readonly AgentInfo[]
readonly commands?: readonly CommandInfo[]
}
export const testModel = {
id: "test-model",
modelID: "test-model",
providerID: "test",
name: "Test Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "default" }, { id: "high" }],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 100_000, output: 10_000 },
} satisfies ModelInfo
export const secondModel = {
id: "second-model",
modelID: "second-model",
providerID: "test",
name: "Second Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "low" }, { id: "medium" }],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 200_000, output: 20_000 },
} satisfies ModelInfo
export const buildAgent = {
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
export const planAgent = {
id: "plan",
name: "Plan",
description: "Plan first",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
export const reviewCommand = {
name: "review",
description: "Review changes",
} satisfies CommandInfo
export function makeSession(
id: string,
input: {
readonly cwd?: string
readonly agent?: string
readonly model?: ModelRef
readonly cost?: number
readonly tokens?: TokenUsageInfo
readonly time?: SessionInfo["time"]
readonly title?: string
} = {},
): SessionInfo {
return {
id,
projectID: "global",
agent: input.agent ?? "build",
model: input.model ?? { providerID: "test", id: "test-model", variant: "default" },
cost: input.cost ?? 0,
tokens: input.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: input.time ?? { created: 0, updated: 0 },
title: input.title ?? `Session ${id}`,
location: { directory: input.cwd ?? "/workspace" },
}
}
export function makeACPFixture(options: FixtureOptions = {}) {
const requests: FixtureRequest[] = []
const updates: SessionNotification[] = []
const encoder = new TextEncoder()
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
const models = options.models ?? [testModel, secondModel]
const context: FixtureContext = {
requests,
send(event) {
if (!eventController) throw new Error("ACP fixture has no active event stream")
eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
},
}
const server = Bun.serve({
port: 0,
async fetch(raw) {
const url = new URL(raw.url)
const request: FixtureRequest = {
method: raw.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
body: raw.method === "GET" || raw.method === "HEAD" ? undefined : await raw.json().catch(() => undefined),
}
requests.push(request)
const response = await options.fetch?.(request, context)
if (response) return response
const directory = request.query["location[directory]"] ?? "/workspace"
const location = { directory, project: { id: "global", directory } }
if (request.path === "/api/event") {
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
start(value) {
controller = value
eventController = value
context.send({ id: "evt_connected", type: "server.connected", data: {} })
},
cancel() {
if (eventController === controller) eventController = undefined
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
if (request.path === "/api/model") return Response.json({ location, data: models })
if (request.path === "/api/model/default") {
return Response.json({ location, data: options.defaultModel ?? models[0] ?? null })
}
if (request.path === "/api/agent") {
return Response.json({ location, data: options.agents ?? [buildAgent, planAgent] })
}
if (request.path === "/api/command") {
return Response.json({ location, data: options.commands ?? [reviewCommand] })
}
return new Response(null, { status: 404 })
},
})
const service = ACPService.make({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
})
return {
service,
requests,
updates,
send: context.send,
async [Symbol.asyncDispose]() {
eventController?.close()
await server.stop(true)
},
}
}
@@ -1,302 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
describe("acp service lifecycle", () => {
test("does not persist the first catalog variant when no explicit default exists", async () => {
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
await using fixture = makeACPFixture({
models: [model],
defaultModel: model,
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({
data: makeSession("ses_default_variant", {
model: { providerID: model.providerID, id: model.id },
}),
})
}
return undefined
},
})
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(fixture.requests).toContainEqual({
method: "POST",
path: "/api/session",
query: {},
body: { location: { directory: "/workspace" } },
})
expect(currentValue(created, "effort")).toBe("default")
})
test("loads and forks with paginated replay while resume does not replay", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "GET" && request.path === "/api/session/ses_loaded") {
return Response.json({
data: makeSession("ses_loaded", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "medium" },
}),
})
}
if (request.method === "GET" && request.path === "/api/session/ses_resume") {
return Response.json({
data: makeSession("ses_resume", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "low" },
}),
})
}
if (request.method === "POST" && request.path === "/api/session/ses_loaded/fork") {
return Response.json({
data: makeSession("ses_fork", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "medium" },
}),
})
}
if (request.method === "GET" && request.path === "/api/session/ses_loaded/message") {
if (request.query.cursor === "messages-2") {
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
content: [{ type: "text", text: "hi there" }],
},
],
cursor: {},
})
}
return Response.json({
data: [{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } }],
cursor: { next: "messages-2" },
})
}
if (request.method === "GET" && request.path === "/api/session/ses_fork/message") {
return Response.json({
data: [{ id: "msg_fork", type: "user", text: "forked", time: { created: 2 } }],
cursor: {},
})
}
return undefined
},
})
const loaded = await fixture.service.loadSession({
cwd: "/workspace",
sessionId: "ses_loaded",
mcpServers: [],
})
const resumed = await fixture.service.resumeSession({
cwd: "/workspace",
sessionId: "ses_resume",
mcpServers: [],
})
const forked = await fixture.service.forkSession({
cwd: "/workspace",
sessionId: "ses_loaded",
mcpServers: [],
})
const mismatched = await fixture.service
.loadSession({ cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })
.catch((error: unknown) => error)
expect(mismatched).toMatchObject({ _tag: "ACPSessionDirectoryMismatchError", cwd: "/elsewhere" })
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(forked.sessionId).toBe("ses_fork")
expect(currentValue(forked, "effort")).toBe("medium")
expect(
fixture.updates.filter(
(item) =>
item.update.sessionUpdate === "user_message_chunk" || item.update.sessionUpdate === "agent_message_chunk",
),
).toEqual([
{
sessionId: "ses_loaded",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_user",
content: { type: "text", text: "hello" },
},
},
{
sessionId: "ses_loaded",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hi there" },
},
},
{
sessionId: "ses_fork",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_fork",
content: { type: "text", text: "forked" },
},
},
])
expect(
fixture.requests
.filter((request) => request.path.endsWith("/message"))
.map((request) => ({ path: request.path, query: request.query })),
).toEqual([
{
path: "/api/session/ses_loaded/message",
query: { limit: "200", order: "asc" },
},
{
path: "/api/session/ses_loaded/message",
query: { limit: "200", cursor: "messages-2" },
},
{
path: "/api/session/ses_fork/message",
query: { limit: "200", order: "asc" },
},
])
expect(fixture.requests).toContainEqual({
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: {},
})
})
test("lists server-backed pages and forwards cwd and cursor", async () => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
makeSession(`ses_${100 - index}`, {
cwd: "/workspace",
time: { created: index, updated: 100_000 - index },
title: `Session ${100 - index}`,
}),
)
await using fixture = makeACPFixture({
fetch(request) {
if (request.method !== "GET" || request.path !== "/api/session") return undefined
if (request.query.cursor === "page-2") {
return Response.json({
data: [makeSession("ses_0", { cwd: "/workspace", time: { created: 0, updated: 1 } })],
cursor: {},
})
}
return Response.json({ data: firstPage, cursor: { next: "page-2" } })
},
})
const first = await fixture.service.listSessions({ cwd: "/workspace" })
const second = await fixture.service.listSessions({ cwd: "/workspace", cursor: first.nextCursor })
expect(first.sessions).toHaveLength(100)
expect(first.sessions[0]).toEqual({
sessionId: "ses_100",
cwd: "/workspace",
title: "Session 100",
updatedAt: new Date(100_000).toISOString(),
})
expect(first.nextCursor).toBe("page-2")
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
expect(second.nextCursor).toBeUndefined()
expect(
fixture.requests.filter((request) => request.path === "/api/session").map((request) => request.query),
).toEqual([
{ limit: "100", order: "desc", directory: "/workspace" },
{ limit: "100", order: "desc", directory: "/workspace", cursor: "page-2" },
])
})
test("cancel preserves the attachment while close removes it and surfaces interrupt failures", async () => {
const interrupt = { fail: true }
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_lifecycle") })
}
if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/missing/interrupt") {
return Response.json(
{ _tag: "SessionNotFoundError", sessionID: "missing", message: "session not found" },
{ status: 404 },
)
}
if (request.method === "POST" && request.path.endsWith("/interrupt")) {
return interrupt.fail ? new Response(null, { status: 500 }) : Response.json({ interrupted: false })
}
return undefined
},
})
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
await fixture.service.cancel({ sessionId: created.sessionId })
const updated = await fixture.service.setSessionConfigOption({
sessionId: created.sessionId,
configId: "effort",
value: "high",
})
expect(currentValue(updated, "effort")).toBe("high")
const failed = await fixture.service.closeSession({ sessionId: created.sessionId }).catch((error: unknown) => error)
expect(failed).toMatchObject({ name: "ClientError", reason: "UnexpectedStatus" })
interrupt.fail = false
expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
const missing = await fixture.service
.setSessionConfigOption({
sessionId: created.sessionId,
configId: "effort",
value: "default",
})
.catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: created.sessionId })
expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
expect(
fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
).toEqual([
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/missing/interrupt",
])
})
test("deletes sessions from backing and local storage", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_delete") })
}
if (request.method === "DELETE" && request.path === "/api/session/ses_delete") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({})
expect(fixture.requests).toContainEqual({
method: "DELETE",
path: "/api/session/ses_delete",
query: {},
body: undefined,
})
const missing = await fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
.catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
})
})
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
return result.configOptions?.find((option) => option.id === id)?.currentValue
}
-233
View File
@@ -1,233 +0,0 @@
import { describe, expect, test } from "bun:test"
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
describe("acp service prompt routing and usage", () => {
test("routes slash commands and compact through their session endpoints", async () => {
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "GET" && request.path === "/api/session/ses_routes") {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: id },
})
return Response.json({ data: {} })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const commandResult = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/review now" }],
})
const compactResult = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/compact" }],
})
expect([commandResult.stopReason, compactResult.stopReason]).toEqual(["end_turn", "end_turn"])
const command = fixture.requests.find((request) => request.path === "/api/session/ses_routes/command")
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
name: "review",
text: "now",
files: [],
delivery: "steer",
})
expect(compact?.body).toMatchObject({ id: expect.any(String) })
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
})
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
const assistantTokens = {
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_usage") })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage/model") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage/prompt") {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_usage", inboxID: id },
})
context.send({
id: "evt_step",
type: "session.step.ended",
data: {
sessionID: "ses_usage",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0.5,
tokens: assistantTokens,
},
})
context.send({
id: "evt_done",
type: "session.execution.succeeded",
data: { sessionID: "ses_usage" },
})
return Response.json({ data: {} })
}
if (request.method === "GET" && request.path === "/api/session/ses_usage/message/msg_assistant") {
return Response.json({
data: {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: secondModel.id },
content: [{ type: "text", text: "done" }],
finish: "stop",
tokens: assistantTokens,
time: { created: 1, completed: 2 },
},
})
}
if (request.method === "GET" && request.path === "/api/session/ses_usage") {
return Response.json({
data: makeSession("ses_usage", {
model: { providerID: "test", id: secondModel.id },
cost: 3.5,
tokens: { input: 120, output: 50, reasoning: 8, cache: { read: 30, write: 4 } },
}),
})
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
const response = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello" }],
})
expect(response).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
},
_meta: {},
})
expect(fixture.updates.filter((item) => item.update.sessionUpdate === "usage_update")).toEqual([
{
sessionId: "ses_usage",
update: {
sessionUpdate: "usage_update",
used: 171,
size: 200_000,
cost: { amount: 3.5, currency: "USD" },
},
},
])
})
test("does not fail a completed prompt when the usage refresh fails", async () => {
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_usage_failure") })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage_failure/prompt") {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_usage_failure", inboxID: id },
})
context.send({
id: "evt_step_failure",
type: "session.step.ended",
data: {
sessionID: "ses_usage_failure",
assistantMessageID: "msg_usage_failure",
finish: "stop",
cost: 0,
tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
context.send({
id: "evt_done_failure",
type: "session.execution.succeeded",
data: { sessionID: "ses_usage_failure" },
})
return Response.json({ data: {} })
}
if (request.method === "GET" && request.path === "/api/session/ses_usage_failure/message/msg_usage_failure") {
return Response.json({
data: {
id: "msg_usage_failure",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "stop",
tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
},
})
}
if (request.method === "GET" && request.path === "/api/session/ses_usage_failure") {
return new Response(null, { status: 500 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const response = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello" }],
})
expect(response.stopReason).toBe("end_turn")
expect(fixture.updates.some((item) => item.update.sessionUpdate === "usage_update")).toBe(false)
})
})
function requestID(request: FixtureRequest) {
if (!request.body || typeof request.body !== "object") throw new Error(`missing body for ${request.path}`)
const id = "id" in request.body ? request.body.id : undefined
if (typeof id !== "string") throw new Error(`missing prompt id for ${request.path}`)
return id
}
function completeTurn(context: FixtureContext, sessionID: string, start: unknown) {
context.send(start)
context.send({
id: `evt_done_${sessionID}`,
type: "session.execution.succeeded",
data: { sessionID },
})
}
-108
View File
@@ -1,108 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode/client/promise"
import { ACPService } from "../../src/acp/service"
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
describe("acp service", () => {
test("creates a v2 session, registers mcp, and publishes commands", async () => {
const requests: Array<{ method: string; path: string; body?: unknown }> = []
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
requests.push({
method: request.method,
path: url.pathname,
body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
})
const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/experimental/mcp/docs" && request.method === "PUT")
return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
},
})
const service = ACPService.make({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
})
try {
const initialized = await service.initialize({
protocolVersion: 1,
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
clientInfo: { name: "test", version: "1" },
})
const result = await service.newSession({
cwd: "/workspace",
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
})
expect(result.sessionId).toBe("ses_acp")
expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true })
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
path: "/api/experimental/mcp/docs",
body: {
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
},
})
expect(updates.at(-1)).toMatchObject({
sessionId: "ses_acp",
update: {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "review", description: "" }],
},
})
} finally {
await server.stop(true)
}
})
})
const model = {
id: "test-model",
modelID: "test-model",
providerID: "test",
name: "Test Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "default" }, { id: "high" }],
time: { released: 0 },
cost: [],
status: "active" as const,
enabled: true,
limit: { context: 100_000, output: 10_000 },
}
const agent = {
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary" as const,
hidden: false,
permissions: [],
}
const session = {
id: "ses_acp",
projectID: "global",
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "New session",
location: { directory: "/workspace" },
}
+223
View File
@@ -0,0 +1,223 @@
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 },
})
})
})
-208
View File
@@ -1,208 +0,0 @@
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
type RequestRecord = {
readonly method: string
readonly path: string
readonly body?: unknown
}
type FixtureOptions = {
readonly onPrompt?: (input: {
readonly sessionID: string
readonly id: string
readonly body: unknown
readonly signal: AbortSignal
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onInterrupt?: (input: {
readonly sessionID: string
readonly send: (event: unknown) => void
}) => boolean | Promise<boolean>
readonly onPermissionReply?: (input: {
readonly sessionID: string
readonly requestID: string
readonly reply: string
readonly body: unknown
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onFormCancel?: (input: {
readonly sessionID: string
readonly formID: string
readonly send: (event: unknown) => void
}) => void | Promise<void>
}
const ids = { next: 0 }
export function durableEvent<Type extends DurableEvent["type"]>(
type: Type,
data: Extract<DurableEvent, { type: Type }>["data"],
) {
ids.next++
return {
id: `evt_${ids.next}`,
created: ids.next,
type,
durable: { aggregateID: "test", seq: ids.next, version: 1 },
data,
}
}
export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
type: Type,
data: Extract<EphemeralEvent, { type: Type }>["data"],
) {
ids.next++
return { id: `evt_${ids.next}`, created: ids.next, type, data }
}
export function createSseFixture(options: FixtureOptions = {}) {
const encoder = new TextEncoder()
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>()
const requests: RequestRecord[] = []
const messages = new Map<string, SessionMessageInfo>()
const send = (event: unknown) => {
for (const stream of streams) {
try {
stream.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
} catch {
streams.delete(stream)
}
}
}
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
const body = request.method === "GET" ? undefined : await request.json().catch(() => undefined)
requests.push({ method: request.method, path: url.pathname, ...(body === undefined ? {} : { body }) })
if (url.pathname === "/api/event") {
const state: { stream?: ReadableStreamDefaultController<Uint8Array> } = {}
return new Response(
new ReadableStream<Uint8Array>({
start(stream) {
state.stream = stream
streams.add(stream)
stream.enqueue(
encoder.encode(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
),
)
},
cancel() {
if (state.stream) streams.delete(state.stream)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
const prompt = /^\/api\/session\/([^/]+)\/prompt$/.exec(url.pathname)
if (prompt?.[1]) {
const id = stringField(body, "id")
if (!id) return new Response(null, { status: 400 })
await options.onPrompt?.({
sessionID: decodeURIComponent(prompt[1]),
id,
body,
signal: request.signal,
send,
})
return Response.json({ data: { text: stringField(body, "text") ?? "" } })
}
const message = /^\/api\/session\/([^/]+)\/message\/([^/]+)$/.exec(url.pathname)
if (message?.[1] && message[2]) {
const sessionID = decodeURIComponent(message[1])
const messageID = decodeURIComponent(message[2])
return Response.json({
data: messages.get(`${sessionID}/${messageID}`) ?? messages.get(messageID) ?? assistantMessage(messageID),
})
}
const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
if (permission?.[1] && permission[2]) {
const reply = stringField(body, "decision")
if (!reply) return new Response(null, { status: 400 })
await options.onPermissionReply?.({
sessionID: decodeURIComponent(permission[1]),
requestID: decodeURIComponent(permission[2]),
reply,
body,
send,
})
return new Response(null, { status: 204 })
}
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)$/.exec(url.pathname)
if (request.method === "DELETE" && form?.[1] && form[2]) {
await options.onFormCancel?.({
sessionID: decodeURIComponent(form[1]),
formID: decodeURIComponent(form[2]),
send,
})
return new Response(null, { status: 204 })
}
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
if (interrupt?.[1]) {
const interrupted =
(await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })) ?? false
return Response.json({ interrupted })
}
return new Response(null, { status: 404 })
},
})
return {
client: OpenCode.make({ baseUrl: server.url.toString() }),
messages,
requests,
send,
async stop() {
for (const stream of streams) {
try {
stream.close()
} catch {}
}
streams.clear()
await server.stop(true)
},
}
}
export async function withTimeout<Value>(promise: Promise<Value>, message: string, milliseconds = 2_000) {
const timeout = Promise.withResolvers<never>()
const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds)
try {
return await Promise.race([promise, timeout.promise])
} finally {
clearTimeout(timer)
}
}
function stringField(value: unknown, key: string) {
if (!value || typeof value !== "object") return undefined
const field = (value as Record<string, unknown>)[key]
return typeof field === "string" ? field : undefined
}
function assistantMessage(id: string) {
return {
id,
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "stop",
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
} satisfies SessionMessageInfo
}
+5 -29
View File
@@ -1,9 +1,4 @@
import type {
InitializeResponse,
NewSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk"
import type { InitializeResponse, NewSessionResponse } from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -45,6 +40,8 @@ type Waiter = {
}
export type AcpProcess = {
readonly pid: number
readonly exited: Promise<number>
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly send: <T>(
method: string,
@@ -164,29 +161,6 @@ export function expectOk<T>(response: JsonRpcResponse<T>) {
return response.result
}
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
return options?.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.id === id && option.type === "select",
)
}
export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
if (!option) throw new Error(`Missing ACP config option: ${id}`)
return option
}
export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
}
export function alternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
return value
}
function verifierConfig(llmUrl: string, skills?: string) {
const model = {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
@@ -326,6 +300,8 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
}
return {
pid: child.pid,
exited: child.exited,
request: <T>(method: string, params?: unknown) => send<T>(method, params).response,
send,
notify: (method: string, params: unknown) => write({ jsonrpc: "2.0", method, params }),
+399
View File
@@ -0,0 +1,399 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { resolve } from "node:path"
import {
assistantMessage,
childCreated,
delivered,
durableEvent,
ephemeralEvent,
failed,
reasoningDelta,
startSession,
stepEnded,
succeeded,
textDelta,
toolCalled,
toolFailed,
toolProgress,
toolStarted,
toolSucceeded,
turn,
type ChildUpdate,
} from "./wire-fixture"
describe("acp turn events over the wire", () => {
test("isolates events from other sessions and other inputs", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
textDelta(sessionID, "msg_before", "before admission"),
delivered("ses_other", id),
delivered(sessionID, "input_other"),
textDelta(sessionID, "msg_wrong_input", "wrong input"),
delivered(sessionID, id),
textDelta("ses_other", "msg_other", "other session"),
textDelta(sessionID, "msg_accepted", "accepted"),
stepEnded(sessionID, "msg_accepted"),
succeeded("ses_other"),
succeeded(sessionID),
],
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(turnUpdates(acp.updates)).toEqual([
{
sessionId: acp.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_accepted",
content: { type: "text", text: "accepted" },
},
},
])
expect(response.stopReason).toBe("end_turn")
})
test("streams ordered reasoning and text chunks before admission returns", async () => {
const releaseAdmission = Promise.withResolvers<void>()
await using acp = await startSession({ onPrompt: () => releaseAdmission.promise })
const prompt = acp.prompt(acp.sessionId, "hello")
const settled = { value: false }
void prompt.finally(() => {
settled.value = true
})
const submitted = await acp.until(() => acp.server.prompts[0], "prompt submission")
const sessionID = acp.sessionId
acp.server.send(
...turn(
sessionID,
submitted.id,
reasoningDelta(sessionID, "msg_order", "think-1"),
reasoningDelta(sessionID, "msg_order", " continued"),
textDelta(sessionID, "msg_order", "answer", 1),
reasoningDelta(sessionID, "msg_order", "think-2", 1),
stepEnded(sessionID, "msg_order"),
),
)
await acp.until(() => chunks(acp.updates).length === 4, "streamed chunks")
expect(settled.value).toBe(false)
releaseAdmission.resolve()
expect((await prompt).stopReason).toBe("end_turn")
expect(chunks(acp.updates)).toEqual([
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
])
})
test("replays reasoning parts with the same message IDs as live reasoning ordinals", async () => {
await using acp = await startSession()
acp.server.messages.set(acp.sessionId, [
assistantMessage("msg_order", {
time: { created: 1 },
content: [
{ type: "reasoning", text: "think-1 continued" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "think-2" },
],
}),
])
await acp.request("session/load", { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] })
expect(chunks(acp.updates)).toEqual([
["agent_thought_chunk", "msg_order:reasoning:0", "think-1 continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
])
})
test("projects foreground child session updates onto the parent turn without the child capability", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
toolStarted("ses_child", "call_read", "read"),
toolCalled("ses_child", "call_read", { path: "/workspace/src/index.ts" }),
toolSucceeded("ses_child", "call_read", {}, "source"),
succeeded("ses_child"),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
const updates = turnUpdates(acp.updates)
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate, toolCallID(item)])).toEqual([
[acp.sessionId, "tool_call", "ses_child:call_read"],
[acp.sessionId, "tool_call_update", "ses_child:call_read"],
[acp.sessionId, "tool_call_update", "ses_child:call_read"],
])
expect(updates[0]?.update).toMatchObject({
title: "Explore code: read",
_meta: {
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore code" },
},
})
expect(acp.childUpdates).toEqual([])
expect(response.stopReason).toBe("end_turn")
})
test("routes foreground and nested child updates to the extension when the client supports it", async () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: true },
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
childCreated("ses_grandchild", "ses_child", "Deeper"),
durableEvent("session.execution.started", { sessionID: "ses_grandchild" }),
textDelta("ses_grandchild", "msg_grandchild", "nested"),
failed("ses_grandchild", { type: "tool.error", message: "boom" }),
succeeded("ses_child"),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(turnUpdates(acp.updates)).toEqual([])
expect(acp.childUpdates).toEqual([
expect.objectContaining({ childSessionId: "ses_child", depth: 1, type: "status", status: "created" }),
expect.objectContaining({
childSessionId: "ses_grandchild",
parentSessionId: "ses_child",
rootSessionId: acp.sessionId,
depth: 2,
title: "Deeper",
type: "status",
status: "created",
}),
expect.objectContaining({ childSessionId: "ses_grandchild", type: "status", status: "running" }),
expect.objectContaining({
childSessionId: "ses_grandchild",
type: "update",
update: expect.objectContaining({ sessionUpdate: "agent_message_chunk", messageId: "msg_grandchild" }),
}),
expect.objectContaining({
childSessionId: "ses_grandchild",
type: "status",
status: "failed",
error: { type: "tool.error", message: "boom" },
}),
expect.objectContaining({ childSessionId: "ses_child", type: "status", status: "completed" }),
])
expect(response.stopReason).toBe("end_turn")
})
test("continues child extension updates after the parent turn ends", async () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: true },
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, childCreated("ses_background", sessionID, "Background research")),
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
acp.server.send(
childCreated("ses_future", acp.sessionId, "Later turn child"),
durableEvent("session.execution.started", { sessionID: "ses_future" }),
durableEvent("session.execution.started", { sessionID: "ses_background" }),
toolStarted("ses_background", "call_shell", "shell"),
toolCalled("ses_background", "call_shell", { command: "pwd" }),
toolSucceeded("ses_background", "call_shell", { exit: 0 }, "/workspace"),
succeeded("ses_background"),
)
await acp.until(
() => acp.childUpdates.some((item) => item.type === "status" && item.status === "completed"),
"background child completion",
)
expect(turnUpdates(acp.updates)).toEqual([])
expect(acp.childUpdates.map(childUpdateKind)).toEqual([
"status:created",
"status:running",
"update:tool_call",
"update:tool_call_update",
"update:tool_call_update",
"status:completed",
])
expect(acp.childUpdates[2]).toMatchObject({
rootSessionId: acp.sessionId,
childSessionId: "ses_background",
parentSessionId: acp.sessionId,
depth: 1,
title: "Background research",
type: "update",
update: { toolCallId: "ses_background:call_shell" },
})
expect(acp.childUpdates.some((item) => item.childSessionId === "ses_future")).toBe(false)
})
test("streams tool pending, progress, success, and failure updates", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_ok", "shell"),
toolCalled(sessionID, "call_ok", { command: "printf done", workdir: "sub" }),
toolProgress(sessionID, "call_ok", { phase: 1 }),
toolSucceeded(sessionID, "call_ok", { exit: 0 }, "done"),
toolStarted(sessionID, "call_fail", "read"),
toolCalled(sessionID, "call_fail", { path: "/workspace/missing.ts" }),
toolProgress(sessionID, "call_fail", { bytes: 0 }),
toolFailed(sessionID, "call_fail", {
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
}),
stepEnded(sessionID, "msg_tools"),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
const updates = turnUpdates(acp.updates)
expect(updates.map((item) => [item.update.sessionUpdate, toolStatus(item), toolCallID(item)])).toEqual([
["tool_call", "pending", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "completed", "call_ok"],
["tool_call", "pending", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "failed", "call_fail"],
])
expect(updates[1]?.update).toMatchObject({
title: "printf done",
kind: "execute",
locations: [{ path: resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
})
expect(updates[2]?.update).not.toHaveProperty("content")
expect(updates[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[7]?.update).toMatchObject({
kind: "read",
locations: [{ path: "/workspace/missing.ts" }],
content: [
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
],
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
})
expect(response.stopReason).toBe("end_turn")
})
test("cancels unsupported session forms so execution can continue", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_question",
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", title: "Choice", type: "string" }],
},
}),
],
onFormCancel: ({ sessionID, formID }) => [
ephemeralEvent("form.cancelled", { sessionID, id: formID }),
succeeded(sessionID),
],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
})
test.todo(
"reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)",
async () => {
const patchText = [
"*** Begin Patch",
"*** Update File: /workspace/src/c.ts",
"@@",
"-one",
"+two",
"*** End Patch",
].join("\n")
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "/workspace/src/a.ts", oldString: "a", newString: "b" }),
toolSucceeded(sessionID, "call_edit", {}, "edited"),
toolStarted(sessionID, "call_write", "write"),
toolCalled(sessionID, "call_write", { path: "/workspace/src/b.ts", content: "b" }),
toolSucceeded(sessionID, "call_write", {}, "written"),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
toolSucceeded(sessionID, "call_patch", {}, "patched"),
),
})
await acp.prompt(acp.sessionId, "hello")
const locations = turnUpdates(acp.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update")
.map((item) => [
toolCallID(item),
toolStatus(item),
"locations" in item.update ? item.update.locations : undefined,
])
expect(locations).toEqual([
["call_edit", "in_progress", [{ path: "/workspace/src/a.ts" }]],
["call_edit", "completed", [{ path: "/workspace/src/a.ts" }]],
["call_write", "in_progress", [{ path: "/workspace/src/b.ts" }]],
["call_write", "completed", [{ path: "/workspace/src/b.ts" }]],
["call_patch", "in_progress", [{ path: "/workspace/src/c.ts" }]],
["call_patch", "completed", [{ path: "/workspace/src/c.ts" }]],
])
},
)
})
function turnUpdates(updates: readonly SessionNotification[]) {
return updates.filter(
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
)
}
function chunks(updates: readonly SessionNotification[]) {
return updates.flatMap((item) =>
item.update.sessionUpdate === "agent_message_chunk" || item.update.sessionUpdate === "agent_thought_chunk"
? [
[
item.update.sessionUpdate,
item.update.messageId,
item.update.content.type === "text" ? item.update.content.text : undefined,
],
]
: [],
)
}
function childUpdateKind(item: ChildUpdate) {
return item.type === "status" ? `status:${item.status}` : `update:${item.update.sessionUpdate}`
}
function toolCallID(item: SessionNotification) {
return "toolCallId" in item.update ? item.update.toolCallId : undefined
}
function toolStatus(item: SessionNotification) {
return "status" in item.update ? item.update.status : undefined
}
+777
View File
@@ -0,0 +1,777 @@
import {
client,
ndJsonStream,
RequestError,
type AgentNotificationMethod,
type AgentNotificationParamsByMethod,
type AgentRequestMethod,
type AgentRequestParamsByMethod,
type AgentRequestResponsesByMethod,
type AnyMessage,
type ContentBlock,
type McpServer,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type WriteTextFileRequest,
} from "@agentclientprotocol/sdk"
import {
OpenCode,
type AgentInfo,
type CommandInfo,
type LocationRef,
type ModelInfo,
type ModelRef,
type OpenCodeEvent,
type SessionInfo,
type SessionMessageInfo,
type TokenUsageInfo,
} from "@opencode/client/promise"
import type { BunRequest } from "bun"
import { Option, Schema } from "effect"
import { ACP } from "../../src/acp/agent"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
type EventData<Type extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { type: Type }>["data"]
type AssistantMessage = Extract<SessionMessageInfo, { type: "assistant" }>
export type Events = ReadonlyArray<OpenCodeEvent> | void
type Hook<Input> = (input: Input) => Events | Promise<Events>
export type ServerRequest = {
readonly method: string
readonly path: string
readonly query: Record<string, string>
readonly body: unknown
}
const Files = Schema.Array(Schema.Struct({ uri: Schema.String, name: Schema.optional(Schema.String) }))
const Delivery = Schema.optional(Schema.String)
const PromptBody = Schema.Struct({ id: Schema.String, text: Schema.String, files: Files, delivery: Delivery })
const CommandBody = Schema.Struct({ name: Schema.String, text: Schema.String, files: Files, delivery: Delivery })
const CompactBody = Schema.Struct({ id: Schema.String })
const SyntheticBody = Schema.Struct({
text: Schema.String,
description: Schema.optional(Schema.String),
delivery: Delivery,
resume: Schema.optional(Schema.Boolean),
})
const CreateBody = Schema.Struct({ location: Schema.Struct({ directory: Schema.String }) })
const ModelBody = Schema.Struct({
model: Schema.Struct({ providerID: Schema.String, id: Schema.String, variant: Schema.optional(Schema.String) }),
})
const AgentBody = Schema.Struct({ agent: Schema.String })
const ReplyBody = Schema.Struct({ decision: Schema.Literals(["once", "always", "reject"]) })
const McpBody = Schema.Struct({ config: Schema.Unknown })
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
export type Submission =
| ({ readonly kind: "prompt"; readonly sessionID: string } & typeof PromptBody.Type)
| ({ readonly kind: "command"; readonly sessionID: string } & typeof CommandBody.Type)
| ({ readonly kind: "compact"; readonly sessionID: string } & typeof CompactBody.Type)
| ({ readonly kind: "synthetic"; readonly sessionID: string } & typeof SyntheticBody.Type)
type PromptSubmission = Extract<Submission, { readonly kind: "prompt" }>
export type Selection =
| { readonly sessionID: string; readonly model: typeof ModelBody.Type.model }
| { readonly sessionID: string; readonly agent: string }
const ChildSession = {
rootSessionId: Schema.String,
childSessionId: Schema.String,
parentSessionId: Schema.String,
depth: Schema.Number,
title: Schema.optional(Schema.String),
}
const ChildUpdate = Schema.Union([
Schema.Struct({
...ChildSession,
type: Schema.Literal("update"),
update: Schema.StructWithRest(Schema.Struct({ sessionUpdate: Schema.String }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
}),
Schema.Struct({
...ChildSession,
type: Schema.Literal("status"),
status: Schema.Literals(["created", "running", "completed", "failed", "interrupted"]),
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
}),
])
export type ChildUpdate = typeof ChildUpdate.Type
export type WireOptions = {
readonly fetch?: (request: ServerRequest) => Response | undefined | Promise<Response | undefined>
readonly onPrompt?: Hook<{
readonly sessionID: string
readonly id: string
readonly text: string
readonly signal: AbortSignal
}>
readonly onInterrupt?: Hook<{ readonly sessionID: string }>
readonly onPermissionReply?: Hook<{
readonly sessionID: string
readonly requestID: string
readonly decision: string
}>
readonly onFormCancel?: Hook<{ readonly sessionID: string; readonly formID: string }>
readonly permission?: (
request: RequestPermissionRequest,
signal: AbortSignal,
) => RequestPermissionResponse | Promise<RequestPermissionResponse>
}
type CatalogKind = "model" | "default" | "agent" | "command"
export type Catalog = {
models: ModelInfo[]
// Unset follows the first listed model.
defaultModel?: ModelInfo | null
agents: AgentInfo[]
commands: CommandInfo[]
}
export type InitializeOptions = {
readonly writeTextFile?: boolean
readonly childSessionUpdates?: boolean
readonly terminalAuth?: boolean
}
export const testModel = {
id: "test-model",
modelID: "test-model",
providerID: "test",
name: "Test Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "default" }, { id: "high" }],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 100_000, output: 10_000 },
} satisfies ModelInfo
export const secondModel = {
id: "second-model",
modelID: "second-model",
providerID: "test",
name: "Second Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "low" }, { id: "medium" }],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 200_000, output: 20_000 },
} satisfies ModelInfo
export const buildAgent = {
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
export const planAgent = {
id: "plan",
name: "Plan",
description: "Plan first",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
export const reviewCommand = {
name: "review",
description: "Review changes",
} satisfies CommandInfo
export function makeSession(
id: string,
input: {
readonly cwd?: string
readonly agent?: string
readonly model?: ModelRef
readonly cost?: number
readonly time?: SessionInfo["time"]
} = {},
): SessionInfo {
return {
id,
projectID: "global",
...(input.agent ? { agent: input.agent } : {}),
...(input.model ? { model: input.model } : {}),
cost: input.cost ?? 0,
tokens: tokens(0),
time: input.time ?? { created: 0, updated: 0 },
title: `Session ${id}`,
location: { directory: input.cwd ?? "/workspace" },
}
}
export function assistantMessage(id: string, input: Partial<AssistantMessage> = {}) {
return {
id,
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "stop",
tokens: tokens(),
time: { created: 1, completed: 2 },
...input,
} satisfies SessionMessageInfo
}
export function tokens(value = 1): TokenUsageInfo {
return { input: value, output: value, reasoning: 0, cache: { read: 0, write: 0 } }
}
// The fake server stamps ids and sequence numbers when it sends an event.
function durable<Version extends DurableEvent["durable"]["version"]>(version: Version) {
return <Type extends Extract<DurableEvent, { durable: { version: Version } }>["type"]>(
type: Type,
data: EventData<Type>,
) => ({ id: "", created: 0, type, durable: { aggregateID: "test", seq: 0, version }, data })
}
export const durableEvent = durable(1)
const durableEventV2 = durable(2)
export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
type: Type,
data: EventData<Type>,
location?: LocationRef,
) {
return { id: "", created: 0, type, data, ...(location ? { location } : {}) }
}
export const delivered = (sessionID: string, inboxID: string) =>
durableEvent("session.inbox.delivered", { sessionID, inboxID })
export const succeeded = (sessionID: string) => durableEvent("session.execution.succeeded", { sessionID })
export const interrupted = (sessionID: string) =>
durableEvent("session.execution.interrupted", { sessionID, reason: "user" })
export const failed = (sessionID: string, error: EventData<"session.execution.failed">["error"]) =>
durableEvent("session.execution.failed", { sessionID, error })
export function turn(sessionID: string, inboxID: string, ...events: OpenCodeEvent[]): OpenCodeEvent[] {
return [delivered(sessionID, inboxID), ...events, succeeded(sessionID)]
}
export const textDelta = (sessionID: string, assistantMessageID: string, delta: string, ordinal = 0) =>
ephemeralEvent("session.text.delta", { sessionID, assistantMessageID, ordinal, delta })
export const reasoningDelta = (sessionID: string, assistantMessageID: string, delta: string, ordinal = 0) =>
ephemeralEvent("session.reasoning.delta", { sessionID, assistantMessageID, ordinal, delta })
export const stepEnded = (
sessionID: string,
assistantMessageID: string,
input: { readonly finish?: EventData<"session.step.ended">["finish"]; readonly tokens?: TokenUsageInfo } = {},
) =>
durableEvent("session.step.ended", {
sessionID,
assistantMessageID,
finish: input.finish ?? "stop",
cost: 0,
tokens: input.tokens ?? tokens(),
})
export const childCreated = (sessionID: string, parentID: string, title: string) =>
durableEvent("session.created", {
sessionID,
slug: sessionID,
projectID: "project",
location: { directory: "/workspace" },
parentID,
title,
version: "test",
})
export function toolStarted(sessionID: string, id: string, name: string) {
return durableEvent("session.tool.input.started", { sessionID, assistantMessageID: "msg_tools", id, name })
}
export function toolCalled(sessionID: string, id: string, input: EventData<"session.tool.called">["input"]) {
return durableEvent("session.tool.called", { sessionID, assistantMessageID: "msg_tools", id, input, executed: false })
}
export function toolProgress(sessionID: string, id: string, metadata: EventData<"session.tool.progress">["metadata"]) {
return ephemeralEvent("session.tool.progress", { sessionID, assistantMessageID: "msg_tools", id, metadata })
}
export function toolSucceeded(
sessionID: string,
id: string,
metadata: EventData<"session.tool.success">["metadata"],
text: string,
) {
return durableEventV2("session.tool.success", {
sessionID,
assistantMessageID: "msg_tools",
id,
metadata,
content: [{ type: "text", text }],
executed: true,
})
}
export function toolFailed(
sessionID: string,
id: string,
input: Omit<EventData<"session.tool.failed">, "sessionID" | "assistantMessageID" | "id" | "executed">,
) {
return durableEventV2("session.tool.failed", {
sessionID,
assistantMessageID: "msg_tools",
id,
executed: true,
...input,
})
}
export function permissionAsked(
sessionID: string,
id: string,
input: {
readonly action?: string
readonly metadata?: EventData<"permission.asked">["metadata"]
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {},
) {
return ephemeralEvent("permission.asked", {
id,
sessionID,
action: input.action ?? "shell",
resources: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}),
})
}
export async function startWire(options: WireOptions = {}) {
const waiters = new Set<() => void>()
const changed = () => waiters.forEach((check) => check())
const server = startServer(options, changed)
const received: AnyMessage[] = []
const updates: SessionNotification[] = []
const permissions: RequestPermissionRequest[] = []
const writes: WriteTextFileRequest[] = []
const childUpdates: ChildUpdate[] = []
// Client handlers record SDK-validated params; responses wait until they have seen every earlier agent message.
const counts = { sent: 0, handled: 0 }
const handled = <Value>(list: Value[], value: Value) => {
list.push(value)
counts.handled++
changed()
}
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
const agentConnection = ACP.connect(
OpenCode.make({ baseUrl: server.url }),
ndJsonStream(agentToClient.writable, clientToAgent.readable),
)
const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable)
const connection = client({ name: "test" })
.onNotification("session/update", (ctx) => handled(updates, ctx.params))
.onNotification("opencode/session/child_update", Schema.decodeUnknownSync(ChildUpdate), (ctx) =>
handled(childUpdates, ctx.params),
)
.onRequest("session/request_permission", (ctx) => {
handled(permissions, ctx.params)
return options.permission?.(ctx.params, ctx.signal) ?? { outcome: { outcome: "cancelled" } }
})
.onRequest("fs/write_text_file", (ctx) => {
handled(writes, ctx.params)
return {}
})
.connect({
writable: clientStream.writable,
readable: clientStream.readable.pipeThrough(
new TransformStream<AnyMessage, AnyMessage>({
transform(message, controller) {
received.push(message)
if ("method" in message && !message.method.startsWith("$/")) counts.sent++
controller.enqueue(message)
changed()
},
}),
),
})
const until = <T>(read: () => T | undefined | false, description = "condition", timeout = 5_000) => {
const initial = read()
if (initial !== undefined && initial !== false) return Promise.resolve(initial)
return new Promise<T>((resolve, reject) => {
const check = () => {
const value = read()
if (value === undefined || value === false) return
cleanup()
resolve(value)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`timed out waiting for ${description}`))
}, timeout)
const cleanup = () => {
clearTimeout(timer)
waiters.delete(check)
}
waiters.add(check)
})
}
const request = <Method extends AgentRequestMethod>(
method: Method,
params: AgentRequestParamsByMethod[Method],
signal?: AbortSignal,
): Promise<AgentRequestResponsesByMethod[Method]> =>
connection.agent.request(method, params, signal ? { cancellationSignal: signal } : undefined).finally(() => {
const target = counts.sent
return until(() => counts.handled >= target, "client handlers for every agent message (is one missing?)")
})
const initialize = (capabilities: InitializeOptions = {}) =>
request("initialize", {
protocolVersion: 1,
clientCapabilities: {
...(capabilities.writeTextFile ? { fs: { writeTextFile: true, readTextFile: false } } : {}),
_meta: {
...(capabilities.childSessionUpdates ? { "opencode/child-session-updates": true } : {}),
...(capabilities.terminalAuth ? { "terminal-auth": true } : {}),
},
},
clientInfo: { name: "test", version: "1" },
})
return {
server,
received,
updates,
permissions,
writes,
childUpdates,
request,
until,
initialize,
notify: <Method extends AgentNotificationMethod>(method: Method, params: AgentNotificationParamsByMethod[Method]) =>
connection.agent.notify(method, params),
newSession: (cwd = "/workspace", mcpServers: McpServer[] = []) => request("session/new", { cwd, mcpServers }),
prompt: (sessionId: string, prompt: string | ContentBlock[], signal?: AbortSignal) =>
request(
"session/prompt",
{ sessionId, prompt: typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt },
signal,
),
waitForUpdate: (predicate: (update: SessionNotification) => boolean, description = "session/update") =>
until(() => updates.find(predicate), description),
async [Symbol.asyncDispose]() {
connection.close()
agentConnection.close()
await server.stop()
},
}
}
export type Wire = Awaited<ReturnType<typeof startWire>>
export async function startSession(options: WireOptions & { readonly capabilities?: InitializeOptions } = {}) {
const wire = await startWire(options)
await wire.initialize(options.capabilities)
const session = await wire.newSession()
return Object.assign(wire, { sessionId: session.sessionId, session })
}
export async function rpcError(promise: Promise<unknown>) {
const error = await promise.then(
(result) => {
throw new Error(`expected an ACP error, got ${JSON.stringify(result)}`)
},
(error: unknown) => error,
)
if (!(error instanceof RequestError)) throw error
return { code: error.code, message: error.message, data: error.data }
}
function startServer(options: WireOptions, changed: () => void) {
const encoder = new TextEncoder()
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>()
const counter = { sessions: 0, events: 0 }
const catalog: Catalog = {
models: [testModel, secondModel],
agents: [buildAgent, planAgent],
commands: [reviewCommand],
}
const catalogReads: Array<{ readonly kind: CatalogKind; readonly directory: string }> = []
const requests: ServerRequest[] = []
const submissions: Submission[] = []
const selections: Selection[] = []
const interrupts: string[] = []
const replies: Array<{ readonly sessionID: string; readonly requestID: string; readonly decision: string }> = []
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string }> = []
const mcp: Array<{ readonly name: string; readonly directory?: string; readonly config: unknown }> = []
const fake = {
requests,
catalog,
catalogReads,
sessions: new Map<string, SessionInfo>(),
messages: new Map<string, SessionMessageInfo[]>(),
submissions,
get prompts() {
return submissions.filter((item): item is PromptSubmission => item.kind === "prompt")
},
selections,
interrupts,
replies,
cancelledForms,
mcp,
send(...events: ReadonlyArray<OpenCodeEvent>) {
events.forEach((event) => {
const seq = ++counter.events
const stamped = {
...event,
id: `evt_${seq}`,
created: seq,
...("durable" in event ? { durable: { ...event.durable, seq } } : {}),
}
const chunk = encoder.encode(`data: ${JSON.stringify(stamped)}\n\n`)
streams.forEach((stream) => stream.enqueue(chunk))
})
},
}
const emit = async (events: Events | Promise<Events>) => {
const resolved = await events
if (resolved) fake.send(...resolved)
return resolved?.length ?? 0
}
const createSession = (source: SessionInfo) => {
const session = { ...source, id: `ses_${++counter.sessions}` }
fake.sessions.set(session.id, session)
return session
}
const notFound = (sessionID: string) =>
Response.json({ _tag: "SessionNotFoundError", sessionID, message: "session not found" }, { status: 404 })
const noContent = () => new Response(null, { status: 204 })
// Handlers record facts synchronously before awaiting hooks, so waiters can observe a held request.
const observed = (response: Response | Promise<Response>) => {
changed()
return response
}
const record = async (req: Request) => {
const url = new URL(req.url)
const text = req.method === "GET" ? "" : await req.text()
const request: ServerRequest = {
method: req.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
body: text ? Option.getOrUndefined(decodeJson(text)) : undefined,
}
fake.requests.push(request)
changed()
return { request, text, override: await options.fetch?.(request) }
}
function route<Path extends string>(
handle: (req: BunRequest<Path>, query: Record<string, string>) => Response | Promise<Response>,
) {
return (req: BunRequest<Path>) =>
record(req)
.then((recorded) => recorded.override ?? observed(handle(req, recorded.request.query)))
.finally(changed)
}
function body<Path extends string, A>(
schema: Schema.Codec<A, unknown>,
handle: (req: BunRequest<Path>, body: A, query: Record<string, string>) => Response | Promise<Response>,
) {
const decode = Schema.decodeUnknownOption(Schema.fromJsonString(schema))
return (req: BunRequest<Path>) =>
record(req)
.then((recorded) => {
if (recorded.override) return recorded.override
const parsed = decode(recorded.text)
if (Option.isNone(parsed)) return new Response(null, { status: 400 })
return observed(handle(req, parsed.value, recorded.request.query))
})
.finally(changed)
}
const catalogRoute = (kind: CatalogKind) =>
route((_req, query) => {
const directory = query["location[directory]"] ?? "/workspace"
fake.catalogReads.push({ kind, directory })
const data = {
model: catalog.models,
default: catalog.defaultModel === undefined ? (catalog.models[0] ?? null) : catalog.defaultModel,
agent: catalog.agents,
command: catalog.commands,
}[kind]
return Response.json({ location: { directory, project: { id: "global", directory } }, data })
})
const page = <Item>(items: readonly Item[], query: Record<string, string>, limit: number) => {
const start = Number(query.cursor ?? 0)
const end = start + Number(query.limit ?? limit)
return { data: items.slice(start, end), cursor: end < items.length ? { next: String(end) } : {} }
}
const http = Bun.serve({
port: 0,
fetch: (req) =>
record(req)
.then((recorded) => recorded.override ?? new Response(null, { status: 404 }))
.finally(changed),
routes: {
"/api/event": {
GET: route(() => {
const state: { stream?: ReadableStreamDefaultController<Uint8Array> } = {}
return new Response(
new ReadableStream<Uint8Array>({
start(stream) {
state.stream = stream
streams.add(stream)
stream.enqueue(
encoder.encode(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
),
)
},
cancel() {
if (state.stream) streams.delete(state.stream)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
},
"/api/model": { GET: catalogRoute("model") },
"/api/model/default": { GET: catalogRoute("default") },
"/api/agent": { GET: catalogRoute("agent") },
"/api/command": { GET: catalogRoute("command") },
"/api/session": {
GET: route((_req, query) => {
const sessions = [...fake.sessions.values()]
.filter((session) => !query.directory || session.location.directory === query.directory)
.toSorted((a, b) => b.time.updated - a.time.updated)
return Response.json(page(sessions, query, 100))
}),
POST: body(CreateBody, (_req, input) =>
Response.json({ data: createSession(makeSession("", { cwd: input.location.directory })) }),
),
},
"/api/session/:sessionID": {
GET: route((req) => {
const session = fake.sessions.get(req.params.sessionID)
return session ? Response.json({ data: session }) : notFound(req.params.sessionID)
}),
DELETE: route((req) =>
fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID),
),
},
"/api/session/:sessionID/fork": {
POST: route((req) => {
const source = fake.sessions.get(req.params.sessionID)
if (!source) return notFound(req.params.sessionID)
const forked = createSession(source)
fake.messages.set(forked.id, [...(fake.messages.get(source.id) ?? [])])
return Response.json({ data: forked })
}),
},
"/api/session/:sessionID/model": {
POST: body(ModelBody, (req, input) => {
fake.selections.push({ sessionID: req.params.sessionID, model: input.model })
return noContent()
}),
},
"/api/session/:sessionID/agent": {
POST: body(AgentBody, (req, input) => {
fake.selections.push({ sessionID: req.params.sessionID, agent: input.agent })
return noContent()
}),
},
"/api/session/:sessionID/message": {
GET: route((req, query) => Response.json(page(fake.messages.get(req.params.sessionID) ?? [], query, 200))),
},
"/api/session/:sessionID/message/:messageID": {
GET: route((req) => {
const message = fake.messages.get(req.params.sessionID)?.find((item) => item.id === req.params.messageID)
return message ? Response.json({ data: message }) : new Response(null, { status: 404 })
}),
},
"/api/session/:sessionID/prompt": {
POST: body(PromptBody, async (req, input) => {
const sessionID = req.params.sessionID
fake.submissions.push({ kind: "prompt", sessionID, ...input })
const hook = options.onPrompt ?? (() => turn(sessionID, input.id))
await emit(hook({ sessionID, id: input.id, text: input.text, signal: req.signal }))
return Response.json({ data: { text: input.text } })
}),
},
"/api/session/:sessionID/command": {
POST: body(CommandBody, (req, input) => {
fake.submissions.push({ kind: "command", sessionID: req.params.sessionID, ...input })
return noContent()
}),
},
"/api/session/:sessionID/compact": {
POST: body(CompactBody, (req, input) => {
fake.submissions.push({ kind: "compact", sessionID: req.params.sessionID, ...input })
fake.send(...turn(req.params.sessionID, input.id))
return Response.json({ data: {} })
}),
},
"/api/session/:sessionID/synthetic": {
POST: body(SyntheticBody, (req, input) => {
fake.submissions.push({ kind: "synthetic", sessionID: req.params.sessionID, ...input })
return Response.json({ data: {} })
}),
},
"/api/session/:sessionID/interrupt": {
POST: route(async (req) => {
const sessionID = req.params.sessionID
fake.interrupts.push(sessionID)
if (!fake.sessions.has(sessionID)) return notFound(sessionID)
return Response.json({ interrupted: (await emit(options.onInterrupt?.({ sessionID }))) > 0 })
}),
},
"/api/session/:sessionID/permission/:requestID/reply": {
POST: body(ReplyBody, async (req, input) => {
const reply = { sessionID: req.params.sessionID, requestID: req.params.requestID, decision: input.decision }
fake.replies.push(reply)
await emit(options.onPermissionReply?.(reply))
return noContent()
}),
},
"/api/session/:sessionID/form/:formID": {
DELETE: route(async (req) => {
const form = { sessionID: req.params.sessionID, formID: req.params.formID }
fake.cancelledForms.push(form)
await emit(options.onFormCancel?.(form))
return noContent()
}),
},
"/api/experimental/mcp/:name": {
PUT: body(McpBody, (req, input, query) => {
fake.mcp.push({ name: req.params.name, directory: query["location[directory]"], config: input.config })
return noContent()
}),
},
},
})
return Object.assign(fake, {
url: http.url.toString(),
async stop() {
streams.forEach((stream) => stream.close())
streams.clear()
await http.stop(true)
},
})
}