mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-30 20:47:39 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b9617b1d | ||
|
|
7878744505 |
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]] : []
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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" },
|
||||
}
|
||||
}
|
||||
@@ -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":' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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" },
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user