From 7878744505b5efc7bba8df398346676f6e3ce6af Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 30 Sep 2026 22:09:51 +0530 Subject: [PATCH] test(acp): drive ACP tests through the wire protocol (#52307) --- packages/cli/test/acp/catalog.test.ts | 223 +++++ .../acp/config-options.subprocess.test.ts | 12 +- packages/cli/test/acp/event-behavior.test.ts | 910 ------------------ packages/cli/test/acp/event.test.ts | 162 ---- .../cli/test/acp/lifecycle.subprocess.test.ts | 29 +- .../cli/test/acp/permission-behavior.test.ts | 634 ------------ packages/cli/test/acp/permission.test.ts | 387 ++++++++ packages/cli/test/acp/prompt.test.ts | 402 ++++++++ packages/cli/test/acp/replay.test.ts | 174 ++++ packages/cli/test/acp/select-options.ts | 34 + .../cli/test/acp/service-directory.test.ts | 419 -------- packages/cli/test/acp/service-fixture.ts | 193 ---- .../cli/test/acp/service-lifecycle.test.ts | 302 ------ packages/cli/test/acp/service-usage.test.ts | 233 ----- packages/cli/test/acp/service.test.ts | 108 --- packages/cli/test/acp/session.test.ts | 223 +++++ packages/cli/test/acp/sse-fixture.ts | 208 ---- packages/cli/test/acp/subprocess.ts | 34 +- packages/cli/test/acp/turn.test.ts | 399 ++++++++ packages/cli/test/acp/wire-fixture.ts | 777 +++++++++++++++ 20 files changed, 2654 insertions(+), 3209 deletions(-) create mode 100644 packages/cli/test/acp/catalog.test.ts delete mode 100644 packages/cli/test/acp/event-behavior.test.ts delete mode 100644 packages/cli/test/acp/event.test.ts delete mode 100644 packages/cli/test/acp/permission-behavior.test.ts create mode 100644 packages/cli/test/acp/permission.test.ts create mode 100644 packages/cli/test/acp/prompt.test.ts create mode 100644 packages/cli/test/acp/replay.test.ts create mode 100644 packages/cli/test/acp/select-options.ts delete mode 100644 packages/cli/test/acp/service-directory.test.ts delete mode 100644 packages/cli/test/acp/service-fixture.ts delete mode 100644 packages/cli/test/acp/service-lifecycle.test.ts delete mode 100644 packages/cli/test/acp/service-usage.test.ts delete mode 100644 packages/cli/test/acp/service.test.ts create mode 100644 packages/cli/test/acp/session.test.ts delete mode 100644 packages/cli/test/acp/sse-fixture.ts create mode 100644 packages/cli/test/acp/turn.test.ts create mode 100644 packages/cli/test/acp/wire-fixture.ts diff --git a/packages/cli/test/acp/catalog.test.ts b/packages/cli/test/acp/catalog.test.ts new file mode 100644 index 00000000000..0b19e161218 --- /dev/null +++ b/packages/cli/test/acp/catalog.test.ts @@ -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") } +} diff --git a/packages/cli/test/acp/config-options.subprocess.test.ts b/packages/cli/test/acp/config-options.subprocess.test.ts index 826650c3edc..4d2b50d0b2e 100644 --- a/packages/cli/test/acp/config-options.subprocess.test.ts +++ b/packages/cli/test/acp/config-options.subprocess.test.ts @@ -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 () => { diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts deleted file mode 100644 index 4a23b5bde6b..00000000000 --- a/packages/cli/test/acp/event-behavior.test.ts +++ /dev/null @@ -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[0] -type Connection = Pick -type Fixture = ReturnType - -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() - const releaseUpdate = Promise.withResolvers() - const allUpdates = Promise.withResolvers() - const releaseSubmit = Promise.withResolvers() - 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() - 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 - - 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 - - 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() - 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() - const control: TurnControl = { cancelled: false, admission: new AbortController() } - const fixture = createSseFixture({ - onPrompt({ signal }) { - submitted.resolve() - return new Promise((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 -}) { - 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 -} diff --git a/packages/cli/test/acp/event.test.ts b/packages/cli/test/acp/event.test.ts deleted file mode 100644 index 8b01fc3ac73..00000000000 --- a/packages/cli/test/acp/event.test.ts +++ /dev/null @@ -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 | undefined - const updates: Parameters[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({ - 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, 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({ - 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) - } -}) diff --git a/packages/cli/test/acp/lifecycle.subprocess.test.ts b/packages/cli/test/acp/lifecycle.subprocess.test.ts index 1e03b80be85..72d2a210300 100644 --- a/packages/cli/test/acp/lifecycle.subprocess.test.ts +++ b/packages/cli/test/acp/lifecycle.subprocess.test.ts @@ -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, + ) }) diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts deleted file mode 100644 index ad8ab1e63eb..00000000000 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ /dev/null @@ -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 -type Fixture = ReturnType - -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 => { - 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() - const releaseFirst = Promise.withResolvers() - 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() - const releaseBlocked = Promise.withResolvers() - const promptIDs = new Map() - 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() - 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((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 - 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]] : [] - }) -} diff --git a/packages/cli/test/acp/permission.test.ts b/packages/cli/test/acp/permission.test.ts new file mode 100644 index 00000000000..697da10a415 --- /dev/null +++ b/packages/cli/test/acp/permission.test.ts @@ -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() + 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() + 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]) +} diff --git a/packages/cli/test/acp/prompt.test.ts b/packages/cli/test/acp/prompt.test.ts new file mode 100644 index 00000000000..aefa3a88756 --- /dev/null +++ b/packages/cli/test/acp/prompt.test.ts @@ -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() + await using acp = await startSession({ + onPrompt: ({ signal }) => + new Promise((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" }, + } +} diff --git a/packages/cli/test/acp/replay.test.ts b/packages/cli/test/acp/replay.test.ts new file mode 100644 index 00000000000..1ba5fef055e --- /dev/null +++ b/packages/cli/test/acp/replay.test.ts @@ -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) { + 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":' }, + }, + ], + }), + ] +} diff --git a/packages/cli/test/acp/select-options.ts b/packages/cli/test/acp/select-options.ts new file mode 100644 index 00000000000..19243bd9a2d --- /dev/null +++ b/packages/cli/test/acp/select-options.ts @@ -0,0 +1,34 @@ +import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk" + +type SelectOption = Extract + +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 +} diff --git a/packages/cli/test/acp/service-directory.test.ts b/packages/cli/test/acp/service-directory.test.ts deleted file mode 100644 index e2a3948aa01..00000000000 --- a/packages/cli/test/acp/service-directory.test.ts +++ /dev/null @@ -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(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 -} diff --git a/packages/cli/test/acp/service-fixture.ts b/packages/cli/test/acp/service-fixture.ts deleted file mode 100644 index 167a37cbd65..00000000000 --- a/packages/cli/test/acp/service-fixture.ts +++ /dev/null @@ -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 - readonly body: unknown -} - -export type FixtureContext = { - readonly requests: FixtureRequest[] - send(event: unknown): void -} - -type FixtureHandler = ( - request: FixtureRequest, - context: FixtureContext, -) => Response | undefined | Promise - -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 | 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 | undefined - return new Response( - new ReadableStream({ - 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) - }, - } -} diff --git a/packages/cli/test/acp/service-lifecycle.test.ts b/packages/cli/test/acp/service-lifecycle.test.ts deleted file mode 100644 index cb12a67332e..00000000000 --- a/packages/cli/test/acp/service-lifecycle.test.ts +++ /dev/null @@ -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 -} diff --git a/packages/cli/test/acp/service-usage.test.ts b/packages/cli/test/acp/service-usage.test.ts deleted file mode 100644 index b600399d3ab..00000000000 --- a/packages/cli/test/acp/service-usage.test.ts +++ /dev/null @@ -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 }, - }) -} diff --git a/packages/cli/test/acp/service.test.ts b/packages/cli/test/acp/service.test.ts deleted file mode 100644 index f8b43dc5536..00000000000 --- a/packages/cli/test/acp/service.test.ts +++ /dev/null @@ -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[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" }, -} diff --git a/packages/cli/test/acp/session.test.ts b/packages/cli/test/acp/session.test.ts new file mode 100644 index 00000000000..50ae999b9b5 --- /dev/null +++ b/packages/cli/test/acp/session.test.ts @@ -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 }, + }) + }) +}) diff --git a/packages/cli/test/acp/sse-fixture.ts b/packages/cli/test/acp/sse-fixture.ts deleted file mode 100644 index fba505f3643..00000000000 --- a/packages/cli/test/acp/sse-fixture.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise" - -type DurableEvent = Extract -type EphemeralEvent = Exclude - -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 - readonly onInterrupt?: (input: { - readonly sessionID: string - readonly send: (event: unknown) => void - }) => boolean | Promise - readonly onPermissionReply?: (input: { - readonly sessionID: string - readonly requestID: string - readonly reply: string - readonly body: unknown - readonly send: (event: unknown) => void - }) => void | Promise - readonly onFormCancel?: (input: { - readonly sessionID: string - readonly formID: string - readonly send: (event: unknown) => void - }) => void | Promise -} - -const ids = { next: 0 } - -export function durableEvent( - type: Type, - data: Extract["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: Type, - data: Extract["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>() - const requests: RequestRecord[] = [] - const messages = new Map() - - 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 } = {} - return new Response( - new ReadableStream({ - 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(promise: Promise, message: string, milliseconds = 2_000) { - const timeout = Promise.withResolvers() - 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)[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 -} diff --git a/packages/cli/test/acp/subprocess.ts b/packages/cli/test/acp/subprocess.ts index 880e28a30a6..2968bd6117c 100644 --- a/packages/cli/test/acp/subprocess.ts +++ b/packages/cli/test/acp/subprocess.ts @@ -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 readonly request: (method: string, params?: unknown) => Promise> readonly send: ( method: string, @@ -164,29 +161,6 @@ export function expectOk(response: JsonRpcResponse) { return response.result } -export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) { - return options?.find( - (option): option is Extract => - 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) { - return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options)) -} - -export function alternateValue(option: Extract) { - 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 }): } return { + pid: child.pid, + exited: child.exited, request: (method: string, params?: unknown) => send(method, params).response, send, notify: (method: string, params: unknown) => write({ jsonrpc: "2.0", method, params }), diff --git a/packages/cli/test/acp/turn.test.ts b/packages/cli/test/acp/turn.test.ts new file mode 100644 index 00000000000..36ce7044630 --- /dev/null +++ b/packages/cli/test/acp/turn.test.ts @@ -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() + 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 +} diff --git a/packages/cli/test/acp/wire-fixture.ts b/packages/cli/test/acp/wire-fixture.ts new file mode 100644 index 00000000000..01e3da975c7 --- /dev/null +++ b/packages/cli/test/acp/wire-fixture.ts @@ -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 +type EphemeralEvent = Exclude +type EventData = Extract["data"] +type AssistantMessage = Extract + +export type Events = ReadonlyArray | void +type Hook = (input: Input) => Events | Promise + +export type ServerRequest = { + readonly method: string + readonly path: string + readonly query: Record + 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 + +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 + 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 +} + +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 = {}) { + 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: Version) { + return ["type"]>( + type: Type, + data: EventData, + ) => ({ id: "", created: 0, type, durable: { aggregateID: "test", seq: 0, version }, data }) +} + +export const durableEvent = durable(1) +const durableEventV2 = durable(2) + +export function ephemeralEvent( + type: Type, + data: EventData, + 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, "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 = (list: Value[], value: Value) => { + list.push(value) + counts.handled++ + changed() + } + + const clientToAgent = new TransformStream() + const agentToClient = new TransformStream() + 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({ + transform(message, controller) { + received.push(message) + if ("method" in message && !message.method.startsWith("$/")) counts.sent++ + controller.enqueue(message) + changed() + }, + }), + ), + }) + + const until = (read: () => T | undefined | false, description = "condition", timeout = 5_000) => { + const initial = read() + if (initial !== undefined && initial !== false) return Promise.resolve(initial) + return new Promise((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: Method, + params: AgentRequestParamsByMethod[Method], + signal?: AbortSignal, + ): Promise => + 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: 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> + +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) { + 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>() + 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(), + messages: new Map(), + submissions, + get prompts() { + return submissions.filter((item): item is PromptSubmission => item.kind === "prompt") + }, + selections, + interrupts, + replies, + cancelledForms, + mcp, + send(...events: ReadonlyArray) { + 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) => { + 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) => { + 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( + handle: (req: BunRequest, query: Record) => Response | Promise, + ) { + return (req: BunRequest) => + record(req) + .then((recorded) => recorded.override ?? observed(handle(req, recorded.request.query))) + .finally(changed) + } + function body( + schema: Schema.Codec, + handle: (req: BunRequest, body: A, query: Record) => Response | Promise, + ) { + const decode = Schema.decodeUnknownOption(Schema.fromJsonString(schema)) + return (req: BunRequest) => + 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 = (items: readonly Item[], query: Record, 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 } = {} + return new Response( + new ReadableStream({ + 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) + }, + }) +}