feat(acp): promote next implementation (#29929)

This commit is contained in:
Shoubhit Dash
2026-05-30 01:34:44 +05:30
committed by GitHub
parent 0733c080c0
commit 4cc166a400
39 changed files with 499 additions and 4217 deletions

View File

@@ -1,52 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ACP } from "../../src/acp/agent"
import type { Agent as ACPAgent } from "@agentclientprotocol/sdk"
/**
* Type-level test: This line will fail to compile if ACP.Agent
* doesn't properly implement the ACPAgent interface.
*
* The SDK checks for methods like `agent.unstable_setSessionModel` at runtime
* and throws "Method not found" if they're missing. TypeScript allows optional
* interface methods to be omitted, but the SDK still expects them.
*
* @see https://github.com/agentclientprotocol/typescript-sdk/commit/7072d3f
*/
type _AssertAgentImplementsACPAgent = ACP.Agent extends ACPAgent ? true : never
const _typeCheck: _AssertAgentImplementsACPAgent = true
/**
* Runtime verification that optional methods the SDK expects are actually implemented.
* The SDK's router checks `if (!agent.methodName)` and throws MethodNotFound if missing.
*/
describe("acp.agent interface compliance", () => {
// Extract method names from the ACPAgent interface type
type ACPAgentMethods = keyof ACPAgent
// Methods that the SDK's router explicitly checks for at runtime
const sdkCheckedMethods: ACPAgentMethods[] = [
// Required
"initialize",
"newSession",
"prompt",
"cancel",
// Optional but checked by SDK router
"loadSession",
"setSessionMode",
"authenticate",
// Capability-gated methods checked by the SDK router
"listSessions",
"resumeSession",
"closeSession",
"unstable_forkSession",
"unstable_setSessionModel",
]
test("Agent implements all SDK-checked methods", () => {
for (const method of sdkCheckedMethods) {
expect(typeof ACP.Agent.prototype[method as keyof typeof ACP.Agent.prototype], `Missing method: ${method}`).toBe(
"function",
)
}
})
})

View File

@@ -0,0 +1,229 @@
import { describe, expect, test } from "bun:test"
import {
buildConfigOptions,
buildEffortSelectOption,
buildModeSelectOption,
buildModelSelectOption,
formatCurrentModelId,
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
} from "@/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
id: "anthropic",
name: "Anthropic",
models: {
"claude/sonnet-4": {
id: "claude/sonnet-4",
name: "Claude Sonnet 4",
variants: {
default: {},
high: {},
"very-high": {},
},
},
"claude-haiku": {
id: "claude-haiku",
name: "Claude Haiku",
},
},
},
{
id: "openai",
name: "OpenAI",
models: {
"gpt-5": {
id: "gpt-5",
name: "GPT-5",
variants: {
minimal: {},
low: {},
},
},
},
},
]
describe("acp config options", () => {
test("builds the model select option with ACP verifier category", () => {
expect(
buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "high",
}),
).toEqual({
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "anthropic/claude/sonnet-4",
options: [
{ value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
{ value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
],
})
})
test("includes variant ids in the model option only when requested", () => {
const option = buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "high",
includeVariants: true,
})
expect(option.currentValue).toBe("anthropic/claude/sonnet-4/high")
if (option.type !== "select") throw new Error("expected select option")
expect(option.options).toContainEqual({
value: "anthropic/claude/sonnet-4/high",
name: "Anthropic/Claude Sonnet 4 (High)",
})
expect(option.options).not.toContainEqual({
value: "anthropic/claude/sonnet-4/default",
name: "Anthropic/Claude Sonnet 4 (Default)",
})
})
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: "default",
options: [
{ value: "low", name: "Low" },
{ value: "default", name: "Default" },
{ value: "high", name: "High" },
],
})
})
test("effort fallback uses the first variant when default is absent", () => {
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" })?.currentValue).toBe(
"minimal",
)
})
test("omits effort option when there are no variants", () => {
expect(buildEffortSelectOption({ variants: [] })).toBeUndefined()
})
test("builds the mode select option with descriptions when present", () => {
expect(
buildModeSelectOption({
currentModeId: "build",
modes: [
{ id: "build", name: "Build", description: "Make code changes" },
{ id: "plan", name: "Plan" },
],
}),
).toEqual({
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: "build",
options: [
{ value: "build", name: "Build", description: "Make code changes" },
{ value: "plan", name: "Plan" },
],
})
})
test("builds full config options with model, effort, and mode in stable order", () => {
const options = buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "very-high",
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
currentModeId: "plan",
})
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
expect(options[1]?.currentValue).toBe("very-high")
})
test("full config options omit effort for models without variants", () => {
expect(
buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
}).map((option) => option.id),
).toEqual(["model"])
})
test("parses provider/model selections", () => {
expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
})
})
test("parses provider/model/variant selections when the base model exposes that variant", () => {
expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
})
})
test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
})
})
test("parses trailing variants for slash-containing model ids", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "high",
})
})
test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
})
})
test("formats current model ids with and without selected variants", () => {
expect(
formatCurrentModelId({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
variants: ["minimal", "low"],
}),
).toBe("openai/gpt-5")
expect(
formatCurrentModelId({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
variants: ["minimal", "low"],
includeVariant: true,
}),
).toBe("openai/gpt-5/low")
})
test("formats current model ids with variant fallback", () => {
expect(
formatCurrentModelId({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "missing",
variants: ["default", "high"],
includeVariant: true,
}),
).toBe("anthropic/claude/sonnet-4/default")
})
test("formats variant names for display", () => {
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
})
})

View File

@@ -0,0 +1,201 @@
import { describe, expect, test } from "bun:test"
import type { ContentBlock } from "@agentclientprotocol/sdk"
import { pathToFileURL } from "node:url"
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
describe("acp content conversion", () => {
test("plain text block becomes a text part", () => {
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
})
test("assistant-only text audience becomes synthetic", () => {
expect(
contentBlockToParts({
type: "text",
text: "internal",
annotations: { audience: ["assistant"] },
}),
).toEqual([{ type: "text", text: "internal", synthetic: true }])
})
test("user-only text audience becomes ignored", () => {
expect(
contentBlockToParts({
type: "text",
text: "visible to user",
annotations: { audience: ["user"] },
}),
).toEqual([{ type: "text", text: "visible to user", ignored: true }])
})
test("image block with base64 data becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "AAAA",
mimeType: "image/png",
uri: "file:///tmp/screenshot.png",
}),
).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "screenshot.png",
mime: "image/png",
},
])
})
test("image block with http URI becomes a file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/jpeg",
uri: "http://example.com/assets/photo.jpg",
}),
).toEqual([
{
type: "file",
url: "http://example.com/assets/photo.jpg",
filename: "photo.jpg",
mime: "image/jpeg",
},
])
})
test("resource_link file URL becomes a file part with name and fallback mime", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "file:///tmp/notes.txt",
name: "client-notes.txt",
}),
).toEqual([
{
type: "file",
url: "file:///tmp/notes.txt",
filename: "client-notes.txt",
mime: "text/plain",
},
])
})
test("resource_link zed path becomes a file URL part", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "zed://workspace?path=/tmp/project/src/app.ts",
name: "app.ts",
mimeType: "text/typescript",
}),
).toEqual([
{
type: "file",
url: pathToFileURL("/tmp/project/src/app.ts").href,
filename: "app.ts",
mime: "text/typescript",
},
])
})
test("resource with text becomes a text part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt",
mimeType: "text/plain",
text: "context",
},
}),
).toEqual([{ type: "text", text: "context" }])
})
test("resource with blob and mimeType becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/report.pdf",
mimeType: "application/pdf",
blob: "JVBERg==",
},
}),
).toEqual([
{
type: "file",
url: "data:application/pdf;base64,JVBERg==",
filename: "report.pdf",
mime: "application/pdf",
},
])
})
test("data URL resource is preserved as a file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "data:text/plain;base64,aGVsbG8=",
mimeType: "text/plain",
blob: "ignored",
},
}),
).toEqual([
{
type: "file",
url: "data:text/plain;base64,aGVsbG8=",
filename: "file",
mime: "text/plain",
},
])
})
test("unsupported blocks are ignored", () => {
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
expect(promptContentToParts([{ type: "unknown", text: "skip" } as unknown as ContentBlock])).toEqual([])
})
})
describe("acp replay conversion", () => {
test("replays text audience annotations", () => {
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
{
content: {
type: "text",
text: "cached",
annotations: { audience: ["assistant"] },
},
},
])
})
test("replays file and data URL parts as ACP content", () => {
expect(
partsToContentChunks([
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
]),
).toEqual([
{
content: {
type: "resource_link",
uri: "file:///tmp/readme.md",
name: "readme.md",
mimeType: "text/markdown",
},
},
{
content: {
type: "resource",
resource: {
uri: pathToFileURL("note.txt").href,
mimeType: "text/plain",
text: "hello",
},
},
},
])
})
})

View File

@@ -0,0 +1,185 @@
import { describe, expect } from "bun:test"
import { Directory } from "@/acp/directory"
import { Command } from "@/command"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
const command = (name: string): Command.Info => ({
name,
source: "command",
template: `run ${name}`,
hints: [],
})
const model = (providerID: ProviderID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
id: ModelID.make(id),
providerID,
api: {
id,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: id,
family: "test",
capabilities: {
temperature: true,
reasoning: Boolean(variants),
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
...(variants ? { variants } : {}),
})
const snapshot = (directory: string) => {
const providerID = ProviderID.make(`provider-${directory}`)
const modelID = ModelID.make(`model-${directory}`)
const providers = {
[providerID]: {
id: providerID,
name: `Provider ${directory}`,
source: "config",
env: [],
options: {},
models: {
[modelID]: model(providerID, modelID, {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}),
[ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
},
},
} satisfies Record<ProviderID, Provider.Info>
return Directory.build({
directory,
providers,
modes: [
{ id: "build", name: `build-${directory}` },
{ id: "plan", name: `plan-${directory}`, description: "plan first" },
],
defaultModeID: "build",
commands: [command(`init-${directory}`), command(`review-${directory}`)],
defaultModel: { providerID, modelID },
})
}
const fakeLayer = (calls: string[]) =>
Directory.layer.pipe(
Layer.provide(
Layer.succeed(
Directory.Loader,
Directory.Loader.of({
load: (directory) =>
Effect.sync(() => {
calls.push(directory)
return snapshot(directory)
}),
}),
),
),
)
describe("ACP directory snapshot", () => {
it.effect("two concurrent callers share one load", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const [first, second] = yield* Effect.all([directory.get("alpha"), directory.get("alpha")], {
concurrency: "unbounded",
})
expect(calls).toEqual(["alpha"])
expect(first).toBe(second)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("warm calls use cached data", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const first = yield* directory.get("alpha")
const second = yield* directory.get("alpha")
expect(calls).toEqual(["alpha"])
expect(first).toBe(second)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("different directories get different snapshots", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const [alpha, beta] = yield* Effect.all([directory.get("alpha"), directory.get("beta")], {
concurrency: "unbounded",
})
expect(calls.toSorted()).toEqual(["alpha", "beta"])
expect(alpha.directory).toBe("alpha")
expect(beta.directory).toBe("beta")
expect(alpha.defaultModel?.providerID).not.toBe(beta.defaultModel?.providerID)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("model variant lookup works", () =>
Effect.gen(function* () {
const directory = yield* Directory.Service
const alpha = yield* directory.get("alpha")
const model = alpha.defaultModel!
expect(directory.variants(alpha, model)).toEqual({
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
})
expect(directory.variants(alpha, { ...model, modelID: ModelID.make("missing") })).toBeUndefined()
}).pipe(Effect.provide(fakeLayer([]))),
)
it.effect("commands and modes are included", () =>
Effect.gen(function* () {
const directory = yield* Directory.Service
const alpha = yield* directory.get("alpha")
expect(alpha.availableCommands.map((item) => item.name)).toEqual(["init-alpha", "review-alpha"])
expect(alpha.availableModes).toEqual([
{ id: "build", name: "build-alpha" },
{ id: "plan", name: "plan-alpha", description: "plan first" },
])
expect(alpha.defaultModeID).toBe("build")
}).pipe(Effect.provide(fakeLayer([]))),
)
it.effect("falls back when the default mode is not available", () =>
Effect.sync(() => {
expect(
Directory.build({
directory: "alpha",
providers: {},
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
defaultModeID: "hidden",
commands: [],
}).defaultModeID,
).toBe("build")
}),
)
})

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import * as ACPError from "../../src/acp/error"
describe("acp.error", () => {
test("maps validation failures to invalid params", () => {
const cases: ACPError.Error[] = [
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
new ACPError.InvalidEffortError({ effort: "extreme" }),
new ACPError.InvalidModeError({ mode: "turbo" }),
]
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
-32602, -32602, -32602, -32602, -32602,
])
})
test("includes safe validation details", () => {
expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
code: -32602,
data: { sessionId: "ses_123" },
})
expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
code: -32602,
data: { modelId: "gpt-missing" },
})
})
test("maps auth required to the SDK auth error", () => {
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError({ providerId: "anthropic" }))
expect(requestError).toBeInstanceOf(RequestError)
expect(requestError.code).toBe(-32000)
expect(requestError.message).toBe("Authentication required: provider authentication required")
expect(requestError.data).toEqual({ providerId: "anthropic" })
})
test("maps unsupported operations to method not found", () => {
const requestError = ACPError.toRequestError(
new ACPError.UnsupportedOperationError({ method: "session/new" }),
)
expect(requestError.code).toBe(-32601)
expect(requestError.data).toEqual({ method: "session/new" })
})
test("maps service failures to safe internal errors", () => {
const requestError = ACPError.toRequestError(
new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
)
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Provider request failed")
expect(requestError.data).toEqual({ service: "provider" })
})
test("wraps unknown defects without leaking raw details", () => {
const requestError = ACPError.toRequestError(
ACPError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
)
const serialized = JSON.stringify(requestError.toErrorResponse())
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Internal service failure")
expect(serialized).not.toContain("sk-ant-secret")
expect(serialized).not.toContain("oauth refresh token")
expect(serialized).not.toContain("stack")
})
})

View File

@@ -1,977 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ACP } from "../../src/acp/agent"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type {
Event,
EventMessagePartUpdated,
ToolStateCompleted,
ToolStatePending,
ToolStateRunning,
} from "@opencode-ai/sdk/v2"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
const pollUntil = async <T>(
check: () => T | undefined | false | Promise<T | undefined | false>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
): Promise<T> => {
const timeoutMs = opts?.timeoutMs ?? 2000
const intervalMs = opts?.intervalMs ?? 5
const started = Date.now()
while (true) {
const v = await check()
if (v !== undefined && v !== null && v !== false) return v as T
if (Date.now() - started > timeoutMs) throw new Error(message)
await new Promise((r) => setTimeout(r, intervalMs))
}
}
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type RequestPermissionParams = Parameters<AgentSideConnection["requestPermission"]>[0]
type RequestPermissionResult = Awaited<ReturnType<AgentSideConnection["requestPermission"]>>
type GlobalEventEnvelope = {
directory?: string
payload?: Event
}
type EventController = {
push: (event: GlobalEventEnvelope) => void
close: () => void
}
function inProgressText(update: SessionUpdateParams["update"]) {
if (update.sessionUpdate !== "tool_call_update") return undefined
if (update.status !== "in_progress") return undefined
if (!update.content || !Array.isArray(update.content)) return undefined
const first = update.content[0]
if (!first || first.type !== "content") return undefined
if (first.content.type !== "text") return undefined
return first.content.text
}
function isToolCallUpdate(
update: SessionUpdateParams["update"],
): update is Extract<SessionUpdateParams["update"], { sessionUpdate: "tool_call_update" }> {
return update.sessionUpdate === "tool_call_update"
}
function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) {
return sessionUpdates
.filter((u) => u.sessionId === sessionId)
.map((u) => u.update)
.filter(isToolCallUpdate)
.find((u) => u.toolCallId === callID && u.status === "completed")
}
function toolEvent(
sessionId: string,
cwd: string,
opts: {
callID: string
tool: string
input: Record<string, unknown>
} & ({ status: "running"; metadata?: Record<string, unknown> } | { status: "pending"; raw: string }),
): GlobalEventEnvelope {
const state: ToolStatePending | ToolStateRunning =
opts.status === "running"
? {
status: "running",
input: opts.input,
...(opts.metadata && { metadata: opts.metadata }),
time: { start: Date.now() },
}
: {
status: "pending",
input: opts.input,
raw: opts.raw,
}
const payload: EventMessagePartUpdated = {
id: `evt_${opts.callID}`,
type: "message.part.updated",
properties: {
sessionID: sessionId,
time: Date.now(),
part: {
id: `part_${opts.callID}`,
sessionID: sessionId,
messageID: `msg_${opts.callID}`,
type: "tool",
callID: opts.callID,
tool: opts.tool,
state,
},
},
}
return { directory: cwd, payload }
}
function completedToolEvent(
sessionId: string,
cwd: string,
opts: {
callID: string
tool: string
input: Record<string, unknown>
output: string
attachments?: ToolStateCompleted["attachments"]
},
): GlobalEventEnvelope {
const state: ToolStateCompleted = {
status: "completed",
input: opts.input,
output: opts.output,
title: opts.tool,
metadata: {},
time: { start: Date.now() - 1, end: Date.now() },
...(opts.attachments && { attachments: opts.attachments }),
}
const payload: EventMessagePartUpdated = {
id: `evt_${opts.callID}`,
type: "message.part.updated",
properties: {
sessionID: sessionId,
time: Date.now(),
part: {
id: `part_${opts.callID}`,
sessionID: sessionId,
messageID: `msg_${opts.callID}`,
type: "tool",
callID: opts.callID,
tool: opts.tool,
state,
},
},
}
return { directory: cwd, payload }
}
function createEventStream() {
const queue: GlobalEventEnvelope[] = []
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
const state = { closed: false }
const push = (event: GlobalEventEnvelope) => {
const waiter = waiters.shift()
if (waiter) {
waiter(event)
return
}
queue.push(event)
}
const close = () => {
state.closed = true
for (const waiter of waiters.splice(0)) {
waiter(undefined)
}
}
const stream = async function* (signal?: AbortSignal) {
while (true) {
if (signal?.aborted) return
const next = queue.shift()
if (next) {
yield next
continue
}
if (state.closed) return
const value = await new Promise<GlobalEventEnvelope | undefined>((resolve) => {
waiters.push(resolve)
if (!signal) return
signal.addEventListener("abort", () => resolve(undefined), { once: true })
})
if (!value) return
yield value
}
}
return { controller: { push, close } satisfies EventController, stream }
}
function createFakeAgent() {
const updates = new Map<string, string[]>()
const chunks = new Map<string, string>()
const sessionUpdates: SessionUpdateParams[] = []
const record = (sessionId: string, type: string) => {
const list = updates.get(sessionId) ?? []
list.push(type)
updates.set(sessionId, list)
}
const connection = {
async sessionUpdate(params: SessionUpdateParams) {
sessionUpdates.push(params)
const update = params.update
const type = update?.sessionUpdate ?? "unknown"
record(params.sessionId, type)
if (update?.sessionUpdate === "agent_message_chunk") {
const content = update.content
if (content?.type !== "text") return
if (typeof content.text !== "string") return
chunks.set(params.sessionId, (chunks.get(params.sessionId) ?? "") + content.text)
}
},
async requestPermission(_params: RequestPermissionParams): Promise<RequestPermissionResult> {
return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult
},
} as unknown as AgentSideConnection
const { controller, stream } = createEventStream()
const calls = {
eventSubscribe: 0,
sessionCreate: 0,
}
const sdk = {
global: {
event: async (opts?: { signal?: AbortSignal }) => {
calls.eventSubscribe++
return { stream: stream(opts?.signal) }
},
},
session: {
create: async (_params?: any) => {
calls.sessionCreate++
return {
data: {
id: `ses_${calls.sessionCreate}`,
time: { created: new Date().toISOString() },
},
}
},
get: async (_params?: any) => {
return {
data: {
id: "ses_1",
time: { created: new Date().toISOString() },
},
}
},
messages: async () => {
return { data: [] }
},
message: async (params?: any) => {
// Return a message with parts that can be looked up by partID
return {
data: {
info: {
role: "assistant",
},
parts: [
{
id: params?.messageID ? `${params.messageID}_part` : "part_1",
type: "text",
text: "",
},
],
},
}
},
},
permission: {
respond: async () => {
return { data: true }
},
},
config: {
providers: async () => {
return {
data: {
providers: [
{
id: "opencode",
name: "opencode",
models: {
"big-pickle": { id: "big-pickle", name: "big-pickle" },
},
},
],
},
}
},
},
app: {
agents: async () => {
return {
data: [
{
name: "build",
description: "build",
mode: "agent",
},
],
}
},
},
command: {
list: async () => {
return { data: [] }
},
},
mcp: {
add: async () => {
return { data: true }
},
},
} as any
const agent = new ACP.Agent(connection, {
sdk,
defaultModel: { providerID: "opencode", modelID: "big-pickle" },
} as any)
const stop = () => {
controller.close()
;(agent as any).eventAbort.abort()
}
return { agent, controller, calls, updates, chunks, sessionUpdates, stop, sdk, connection }
}
describe("acp.agent event subscription", () => {
test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, controller, updates, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
controller.push({
directory: cwd,
payload: {
type: "message.part.delta",
properties: {
sessionID: sessionB,
messageID: "msg_1",
partID: "msg_1_part",
field: "text",
delta: "hello",
},
},
} as any)
await pollUntil(
() => (updates.get(sessionB) ?? []).includes("agent_message_chunk"),
"sessionB never received agent_message_chunk",
)
expect((updates.get(sessionA) ?? []).includes("agent_message_chunk")).toBe(false)
expect((updates.get(sessionB) ?? []).includes("agent_message_chunk")).toBe(true)
stop()
},
})
})
test("does not emit user_message_chunk for live prompt parts", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
controller.push({
directory: cwd,
payload: {
type: "message.part.updated",
properties: {
sessionID: sessionId,
time: Date.now(),
part: {
id: "part_1",
sessionID: sessionId,
messageID: "msg_user",
type: "text",
text: "hello",
},
},
},
} as any)
controller.push({
directory: cwd,
payload: {
type: "message.part.delta",
properties: {
sessionID: sessionId,
messageID: "msg_marker",
partID: "msg_marker_part",
field: "text",
delta: "marker",
},
},
} as any)
await pollUntil(
() =>
sessionUpdates.some((u) => u.sessionId === sessionId && u.update.sessionUpdate === "agent_message_chunk"),
"marker event was never processed",
)
expect(
sessionUpdates
.filter((u) => u.sessionId === sessionId)
.some((u) => u.update.sessionUpdate === "user_message_chunk"),
).toBe(false)
stop()
},
})
})
test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, controller, chunks, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const tokenA = ["ALPHA_", "111", "_X"]
const tokenB = ["BETA_", "222", "_Y"]
const push = (sessionId: string, messageID: string, delta: string) => {
controller.push({
directory: cwd,
payload: {
type: "message.part.delta",
properties: {
sessionID: sessionId,
messageID,
partID: `${messageID}_part`,
field: "text",
delta,
},
},
} as any)
}
push(sessionA, "msg_a", tokenA[0])
push(sessionB, "msg_b", tokenB[0])
push(sessionA, "msg_a", tokenA[1])
push(sessionB, "msg_b", tokenB[1])
push(sessionA, "msg_a", tokenA[2])
push(sessionB, "msg_b", tokenB[2])
await pollUntil(
() =>
(chunks.get(sessionA) ?? "").includes(tokenA.join("")) &&
(chunks.get(sessionB) ?? "").includes(tokenB.join("")),
"interleaved chunks never fully arrived",
)
const a = chunks.get(sessionA) ?? ""
const b = chunks.get(sessionB) ?? ""
expect(a).toContain(tokenA.join(""))
expect(b).toContain(tokenB.join(""))
for (const part of tokenB) expect(a).not.toContain(part)
for (const part of tokenA) expect(b).not.toContain(part)
stop()
},
})
})
test("does not create additional event subscriptions on repeated loadSession()", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, calls, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
expect(calls.eventSubscribe).toBe(1)
stop()
},
})
})
test("permission.asked events are handled and replied", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const permissionReplies: string[] = []
const { agent, controller, stop, sdk } = createFakeAgent()
sdk.permission.reply = async (params: any) => {
permissionReplies.push(params.requestID)
return { data: true }
}
const cwd = "/tmp/opencode-acp-test"
const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
controller.push({
directory: cwd,
payload: {
type: "permission.asked",
properties: {
id: "perm_1",
sessionID: sessionA,
permission: "bash",
patterns: ["*"],
metadata: {},
always: [],
},
},
} as any)
await pollUntil(() => permissionReplies.includes("perm_1"), "perm_1 was never replied")
expect(permissionReplies).toContain("perm_1")
stop()
},
})
})
test("permission prompt on session A does not block message updates for session B", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const permissionReplies: string[] = []
let resolvePermissionA: (() => void) | undefined
const permissionABlocking = new Promise<void>((r) => {
resolvePermissionA = r
})
const { agent, controller, chunks, stop, sdk, connection } = createFakeAgent()
// Make permission request for session A block until we release it
const originalRequestPermission = connection.requestPermission.bind(connection)
let _permissionCalls = 0
connection.requestPermission = async (params: RequestPermissionParams) => {
_permissionCalls++
if (params.sessionId.endsWith("1")) {
await permissionABlocking
}
return originalRequestPermission(params)
}
sdk.permission.reply = async (params: any) => {
permissionReplies.push(params.requestID)
return { data: true }
}
const cwd = "/tmp/opencode-acp-test"
const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
// Push permission.asked for session A (will block)
controller.push({
directory: cwd,
payload: {
type: "permission.asked",
properties: {
id: "perm_a",
sessionID: sessionA,
permission: "bash",
patterns: ["*"],
metadata: {},
always: [],
},
},
} as any)
await pollUntil(() => _permissionCalls > 0, "permission handling for A never started")
controller.push({
directory: cwd,
payload: {
type: "message.part.delta",
properties: {
sessionID: sessionB,
messageID: "msg_b",
partID: "msg_b_part",
field: "text",
delta: "session_b_message",
},
},
} as any)
await pollUntil(
() => (chunks.get(sessionB) ?? "").includes("session_b_message"),
"session B never received its message",
)
expect(chunks.get(sessionB) ?? "").toContain("session_b_message")
expect(permissionReplies).not.toContain("perm_a")
resolvePermissionA!()
await pollUntil(() => permissionReplies.includes("perm_a"), "perm_a was never replied after release")
expect(permissionReplies).toContain("perm_a")
stop()
},
})
})
test("streams running bash output snapshots and de-dupes identical snapshots", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const input = { command: "echo hello", description: "run command" }
for (const output of ["a", "a", "ab"]) {
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
status: "running",
input,
metadata: { output },
}),
)
}
await pollUntil(
() =>
sessionUpdates
.filter((u) => u.sessionId === sessionId)
.filter((u) => isToolCallUpdate(u.update))
.map((u) => inProgressText(u.update))
.filter((t) => t === "ab").length > 0,
"final bash snapshot 'ab' never arrived",
)
const snapshots = sessionUpdates
.filter((u) => u.sessionId === sessionId)
.filter((u) => isToolCallUpdate(u.update))
.map((u) => inProgressText(u.update))
expect(snapshots).toEqual(["a", undefined, "ab"])
stop()
},
})
})
test("emits synthetic pending before first running update for any tool", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_bash",
tool: "bash",
status: "running",
input: { command: "echo hi", description: "run command" },
metadata: { output: "hi\n" },
}),
)
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_read",
tool: "read",
status: "running",
input: { filePath: "/tmp/example.txt" },
}),
)
await pollUntil(
() =>
sessionUpdates
.filter((u) => u.sessionId === sessionId)
.map((u) => u.update.sessionUpdate)
.filter((u) => u === "tool_call" || u === "tool_call_update").length >= 4,
"expected 4 tool_call/tool_call_update events",
)
const types = sessionUpdates
.filter((u) => u.sessionId === sessionId)
.map((u) => u.update.sessionUpdate)
.filter((u) => u === "tool_call" || u === "tool_call_update")
expect(types).toEqual(["tool_call", "tool_call_update", "tool_call", "tool_call_update"])
const pendings = sessionUpdates.filter(
(u) => u.sessionId === sessionId && u.update.sessionUpdate === "tool_call",
)
expect(pendings.every((p) => p.update.sessionUpdate === "tool_call" && p.update.status === "pending")).toBe(
true,
)
stop()
},
})
})
test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const data = Buffer.from("image-data").toString("base64")
controller.push(
completedToolEvent(sessionId, cwd, {
callID: "call_image",
tool: "read",
input: { filePath: "/tmp/image.png" },
output: "Image read successfully",
attachments: [
{
id: "part_image",
sessionID: sessionId,
messageID: "msg_image",
type: "file",
mime: "image/png",
filename: "image.png",
url: `data:image/png;base64,${data}`,
},
{
id: "part_text",
sessionID: sessionId,
messageID: "msg_image",
type: "file",
mime: "text/plain",
filename: "note.txt",
url: "data:text/plain;base64,Zm9v",
},
],
}),
)
await pollUntil(
() => completedToolUpdate(sessionUpdates, sessionId, "call_image"),
"completed tool update for call_image never arrived",
)
const update = completedToolUpdate(sessionUpdates, sessionId, "call_image")
expect(update?.content).toContainEqual({
type: "content",
content: { type: "text", text: "Image read successfully" },
})
expect(update?.content).toContainEqual({
type: "content",
content: { type: "image", mimeType: "image/png", data },
})
expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false)
expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2)
stop()
},
})
})
test("replays completed tool image attachments as ACP tool content blocks", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, sessionUpdates, stop, sdk } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const data = Buffer.from("replay-image").toString("base64")
sdk.session.messages = async () => ({
data: [
{
info: {
role: "assistant",
sessionID: sessionId,
},
parts: [
{
id: "part_replay",
sessionID: sessionId,
messageID: "msg_replay",
type: "tool",
callID: "call_replay_image",
tool: "webfetch",
state: {
status: "completed",
input: { url: "https://example.com/image.png" },
output: "Image fetched successfully",
title: "webfetch",
metadata: {},
time: { start: Date.now() - 1, end: Date.now() },
attachments: [
{
id: "part_replay_image",
sessionID: sessionId,
messageID: "msg_replay",
type: "file",
mime: "image/jpeg",
filename: "image.jpg",
url: `data:image/jpeg;base64,${data}`,
},
],
},
},
],
},
],
})
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image")
expect(update?.content).toContainEqual({
type: "content",
content: { type: "text", text: "Image fetched successfully" },
})
expect(update?.content).toContainEqual({
type: "content",
content: { type: "image", mimeType: "image/jpeg", data },
})
stop()
},
})
})
test("does not emit duplicate synthetic pending after replayed running tool", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const input = { command: "echo hi", description: "run command" }
sdk.session.messages = async () => ({
data: [
{
info: {
role: "assistant",
sessionID: sessionId,
},
parts: [
{
type: "tool",
callID: "call_1",
tool: "bash",
state: {
status: "running",
input,
metadata: { output: "hi\n" },
time: { start: Date.now() },
},
},
],
},
],
})
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
status: "running",
input,
metadata: { output: "hi\nthere\n" },
}),
)
await pollUntil(
() =>
sessionUpdates
.filter((u) => u.sessionId === sessionId)
.map((u) => u.update)
.filter((u) => "toolCallId" in u && u.toolCallId === "call_1")
.map((u) => u.sessionUpdate)
.filter((u) => u === "tool_call" || u === "tool_call_update").length >= 3,
"expected 3 tool events for call_1",
)
const types = sessionUpdates
.filter((u) => u.sessionId === sessionId)
.map((u) => u.update)
.filter((u) => "toolCallId" in u && u.toolCallId === "call_1")
.map((u) => u.sessionUpdate)
.filter((u) => u === "tool_call" || u === "tool_call_update")
expect(types).toEqual(["tool_call", "tool_call_update", "tool_call_update"])
stop()
},
})
})
test("clears bash snapshot marker on pending state", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const input = { command: "echo hello", description: "run command" }
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
status: "running",
input,
metadata: { output: "a" },
}),
)
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
status: "pending",
input,
raw: '{"command":"echo hello"}',
}),
)
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
status: "running",
input,
metadata: { output: "a" },
}),
)
await pollUntil(
() =>
sessionUpdates
.filter((u) => u.sessionId === sessionId)
.filter((u) => isToolCallUpdate(u.update))
.map((u) => inProgressText(u.update))
.filter((t) => t === "a").length >= 2,
"expected two 'a' bash snapshots after pending reset",
)
const snapshots = sessionUpdates
.filter((u) => u.sessionId === sessionId)
.filter((u) => isToolCallUpdate(u.update))
.map((u) => inProgressText(u.update))
expect(snapshots).toEqual(["a", "a"])
stop()
},
})
})
})

View File

@@ -0,0 +1,657 @@
import { describe, expect, it } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ACPEvent } from "@/acp/event"
import * as ACPService from "@/acp/service"
import { Directory } from "@/acp/directory"
import { ACPSession } from "@/acp/session"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type ToolSessionUpdateParams = SessionUpdateParams & {
update: Extract<SessionUpdateParams["update"], { sessionUpdate: "tool_call" | "tool_call_update" }>
}
type GlobalEventEnvelope = {
payload?: Event
}
type DeltaPartType = Extract<Part, { type: "text" | "reasoning" }>["type"]
const pollUntil = async (
check: () => boolean | Promise<boolean>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => {
const started = Date.now()
while (true) {
if (await check()) return
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPSession.Service.use((service) => Effect.succeed(service)),
)
}
function createEventStream() {
const queue: GlobalEventEnvelope[] = []
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
const state = { closed: false }
const push = (event: GlobalEventEnvelope) => {
const waiter = waiters.shift()
if (waiter) {
waiter(event)
return
}
queue.push(event)
}
const close = () => {
state.closed = true
for (const waiter of waiters.splice(0)) {
waiter(undefined)
}
}
const stream = async function* (signal?: AbortSignal) {
while (true) {
if (signal?.aborted) return
const next = queue.shift()
if (next) {
yield next
continue
}
if (state.closed) return
const value = await new Promise<GlobalEventEnvelope | undefined>((resolve) => {
waiters.push(resolve)
signal?.addEventListener("abort", () => resolve(undefined), { once: true })
})
if (!value) return
yield value
}
}
return { push, close, stream }
}
function createHarness(messages: Record<string, SessionMessageResponse> = {}) {
const updates: SessionUpdateParams[] = []
const calls = {
eventSubscribe: 0,
message: 0,
}
const events = createEventStream()
const sdk = {
global: {
event: (options?: { signal?: AbortSignal }) => {
calls.eventSubscribe++
return Promise.resolve({ stream: events.stream(options?.signal) })
},
},
session: {
message: (input: { messageID: string }) => {
calls.message++
return Promise.resolve({ data: messages[input.messageID] })
},
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
messages: () => Promise.resolve({ data: [] }),
},
} as unknown as OpencodeClient
const connection = {
sessionUpdate: (params: SessionUpdateParams) => {
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
const session = makeSessionService()
const subscription = new ACPEvent.Subscription({ sdk, connection, session })
return { calls, connection, events, sdk, session, subscription, updates }
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string): Event {
return {
id: `evt_${sessionID}_${messageID}_${partID}_${delta}`,
type: "message.part.delta",
properties: {
sessionID,
messageID,
partID,
field: "text",
delta,
},
}
}
function partUpdated(sessionID: string, messageID: string, partID: string, type: DeltaPartType): Event {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
type: "message.part.updated",
properties: {
sessionID,
time: Date.now(),
part:
type === "text"
? {
id: partID,
sessionID,
messageID,
type: "text",
text: "",
}
: {
id: partID,
sessionID,
messageID,
type: "reasoning",
text: "",
time: { start: Date.now() },
},
},
}
}
function toolUpdated(part: ToolPart): Event {
return {
id: `evt_${part.sessionID}_${part.messageID}_${part.id}_${part.state.status}`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
time: Date.now(),
part,
},
}
}
function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: Date.now() },
parentID: "msg_parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [
type === "text"
? {
id: partID,
sessionID,
messageID,
type: "text",
text: "",
}
: {
id: partID,
sessionID,
messageID,
type: "reasoning",
text: "",
time: { start: Date.now() },
},
],
} satisfies SessionMessageResponse
}
function assistantToolMessage(part: ToolPart) {
return {
info: {
id: part.messageID,
sessionID: part.sessionID,
role: "assistant",
time: { created: Date.now() },
parentID: "msg_parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [part],
} satisfies SessionMessageResponse
}
function runningTool(
sessionID: string,
callID: string,
output?: string,
input: Record<string, unknown> = { cmd: "printf hello" },
) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
state: {
status: "running",
input,
title: "bash",
...(output !== undefined ? { metadata: { output } } : {}),
time: { start: Date.now() },
},
} satisfies ToolPart
}
function completedTool(
sessionID: string,
callID: string,
output = "done",
attachments: Extract<ToolPart["state"], { status: "completed" }>["attachments"] = [],
) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
state: {
status: "completed",
input: { cmd: "printf done" },
output,
title: "bash",
metadata: { exit: 0 },
time: { start: Date.now() - 1, end: Date.now() },
...(attachments.length ? { attachments } : {}),
},
} satisfies ToolPart
}
function errorTool(sessionID: string, callID: string) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
state: {
status: "error",
input: { cmd: "exit 1" },
error: "failed hard",
metadata: { exit: 1 },
time: { start: Date.now() - 1, end: Date.now() },
},
} satisfies ToolPart
}
function toolUpdates(updates: SessionUpdateParams[]) {
return updates.filter((item): item is ToolSessionUpdateParams => {
return item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
})
}
async function createKnownSession(
session: ACPSession.Interface,
sessionId: string,
part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] },
) {
await Effect.runPromise(session.create({ id: sessionId, cwd: "/workspace" }))
await Effect.runPromise(
session.recordPartMetadata({
sessionId,
messageId: part.messageId,
partId: part.partId,
partType: part.partType,
role: part.role ?? "assistant",
}),
)
}
describe("acp event routing", () => {
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
await createKnownSession(harness.session, "ses_b", { messageId: "msg_b", partId: "part_b", partType: "text" })
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "hello"))
expect(harness.updates.map((update) => update.sessionId)).toEqual(["ses_b"])
expect(harness.updates[0]?.update.sessionUpdate).toBe("agent_message_chunk")
})
it("keeps interleaved sessions isolated for text and reasoning deltas", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
await createKnownSession(harness.session, "ses_b", {
messageId: "msg_b",
partId: "part_b",
partType: "reasoning",
})
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A1"))
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B1"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A2"))
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B2"))
expect(
harness.updates.filter((update) => update.sessionId === "ses_a").map((update) => update.update.sessionUpdate),
).toEqual(["agent_message_chunk", "agent_message_chunk"])
expect(
harness.updates.filter((update) => update.sessionId === "ses_b").map((update) => update.update.sessionUpdate),
).toEqual(["agent_thought_chunk", "agent_thought_chunk"])
})
it("does not create extra subscriptions on repeated loadSession", async () => {
const harness = createHarness()
let subscription: ACPEvent.Subscription | undefined
const service = ACPService.make({
sdk: harness.sdk,
connection: harness.connection,
directory: {
get: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
refresh: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
variants: Directory.variants,
},
session: harness.session,
eventSubscription: (started) => {
subscription = started
},
})
await pollUntil(() => harness.calls.eventSubscribe === 1, "event subscription did not start")
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
expect(harness.calls.eventSubscribe).toBe(1)
subscription?.stop()
harness.events.close()
})
it("does not call sdk.session.message repeatedly when metadata is known", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
for (const delta of ["a", "b", "c", "d", "e"]) {
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", delta))
}
expect(harness.calls.message).toBe(0)
expect(harness.updates).toHaveLength(5)
})
it("fetches unknown part metadata once and reuses it for later deltas", async () => {
const harness = createHarness({
msg_a: assistantMessage("ses_a", "msg_a", "part_a", "text"),
})
await Effect.runPromise(harness.session.create({ id: "ses_a", cwd: "/workspace" }))
await harness.subscription.handle(partUpdated("ses_a", "msg_a", "part_a", "text"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "a"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "b"))
expect(harness.calls.message).toBe(1)
expect(harness.updates).toHaveLength(2)
})
it("replays loaded session messages sequentially and continues after update failures", async () => {
const events = createEventStream()
const updates: SessionUpdateParams[] = []
const connection = {
sessionUpdate: (params: SessionUpdateParams) => {
if (params.update.sessionUpdate === "tool_call" && params.update.toolCallId === "call_slow") {
return new Promise<void>((resolve) => {
setTimeout(() => {
updates.push(params)
resolve()
}, 20)
})
}
if (params.update.sessionUpdate === "tool_call_update" && params.update.toolCallId === "call_slow") {
return Promise.reject(new Error("replay send failed"))
}
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
let subscription: ACPEvent.Subscription | undefined
const service = ACPService.make({
sdk: {
global: {
event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }),
},
session: {
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
messages: () =>
Promise.resolve({
data: [
assistantToolMessage(completedTool("ses_loaded", "call_slow", "slow")),
assistantToolMessage(completedTool("ses_loaded", "call_after", "after")),
],
}),
},
} as unknown as OpencodeClient,
connection,
directory: {
get: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
refresh: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
variants: Directory.variants,
},
eventSubscription: (started) => {
subscription = started
},
})
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
expect(toolUpdates(updates).map((item) => item.update.toolCallId)).toEqual([
"call_slow",
"call_after",
"call_after",
])
subscription?.stop()
events.close()
})
it("ignores unknown sessions and live user parts without user_message_chunk duplication", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_user", {
messageId: "msg_user",
partId: "part_user",
partType: "text",
role: "user",
})
await harness.subscription.handle(textDelta("ses_missing", "msg_missing", "part_missing", "ignored"))
await harness.subscription.handle(partUpdated("ses_user", "msg_user", "part_live", "text"))
await harness.subscription.handle(textDelta("ses_user", "msg_user", "part_user", "hello"))
expect(harness.updates).toHaveLength(0)
})
it("emits synthetic pending before the first running tool update", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_tool", "call_1", "hello")))
expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
"tool_call",
"tool_call_update",
])
expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" })
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
})
it("does not emit duplicate synthetic pending after a replayed running tool", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_replay", cwd: "/workspace" }))
await harness.subscription.replayMessage(assistantToolMessage(runningTool("ses_replay", "call_replay", "first")))
await harness.subscription.handle(toolUpdated(runningTool("ses_replay", "call_replay", "second")))
expect(toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")).toHaveLength(1)
expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
"tool_call",
"tool_call_update",
"tool_call_update",
])
})
it("dedupes shell output snapshots while still sending status-only running updates", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_shell", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
const updates = toolUpdates(harness.updates)
expect(updates).toHaveLength(3)
expect(updates[1]?.update).toMatchObject({
sessionUpdate: "tool_call_update",
content: [{ type: "content", content: { type: "text", text: "same" } }],
})
expect(updates[2]?.update).toMatchObject({ sessionUpdate: "tool_call_update", status: "in_progress" })
expect("content" in updates[2]!.update).toBe(false)
})
it("clears shell snapshot marker when a tool returns to pending", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_pending", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
await harness.subscription.handle(
toolUpdated({
id: "part_call_pending",
sessionID: "ses_pending",
messageID: "msg_call_pending",
type: "tool",
callID: "call_pending",
tool: "bash",
state: {
status: "pending",
input: { cmd: "printf repeat" },
raw: '{"cmd":"printf repeat"}',
},
}),
)
await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
expect(
toolUpdates(harness.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update")
.map((item) => ("content" in item.update ? item.update.content : undefined)),
).toEqual([
[{ type: "content", content: { type: "text", text: "repeat" } }],
[{ type: "content", content: { type: "text", text: "repeat" } }],
])
})
it("emits completed tool output and rawOutput", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_done", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(completedTool("ses_done", "call_done", "finished")))
expect(harness.updates.at(-1)?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call_done",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "finished" } }],
rawOutput: { output: "finished", metadata: { exit: 0 } },
})
})
it("emits error tool output", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_error", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(errorTool("ses_error", "call_error")))
expect(harness.updates.at(-1)?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call_error",
status: "failed",
content: [{ type: "content", content: { type: "text", text: "failed hard" } }],
rawOutput: { error: "failed hard", metadata: { exit: 1 } },
})
})
it("emits image attachments as ACP image content for live and replayed completed tool updates", async () => {
const harness = createHarness()
const image = Buffer.from("image-data").toString("base64")
const attachment = {
id: "file_image",
sessionID: "ses_image",
messageID: "msg_image",
type: "file",
mime: "image/png",
filename: "image.png",
url: `data:image/png;base64,${image}`,
} as const
await Effect.runPromise(harness.session.create({ id: "ses_image", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(completedTool("ses_image", "call_live", "live", [attachment])))
await harness.subscription.replayMessage(
assistantToolMessage(completedTool("ses_image", "call_replayed", "replayed", [attachment])),
)
expect(
toolUpdates(harness.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed")
.map((item) => ("content" in item.update ? item.update.content : [])),
).toEqual([
[
{ type: "content", content: { type: "text", text: "live" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: image } },
],
[
{ type: "content", content: { type: "text", text: "replayed" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: image } },
],
])
})
})

View File

@@ -0,0 +1,237 @@
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
RequestPermissionRequest,
RequestPermissionResponse,
SessionUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ACPEvent } from "@/acp/event"
import { ACPSession } from "@/acp/session"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
const pollUntil = async (
check: () => boolean | Promise<boolean>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => {
const started = Date.now()
while (true) {
if (await check()) return
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPSession.Service.use((service) => Effect.succeed(service)),
)
}
function createHarness(
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
) {
const replies: PermissionReplyParams[] = []
const requests: RequestPermissionRequest[] = []
const updates: SessionUpdateParams[] = []
const session = makeSessionService()
const sdk = {
permission: {
reply: (params: PermissionReplyParams) => {
replies.push(params)
return Promise.resolve({ data: true })
},
},
session: {
message: () => Promise.resolve({ data: undefined }),
},
} as unknown as OpencodeClient
const connection = {
requestPermission: (params: RequestPermissionRequest) => {
requests.push(params)
return requestPermission(params)
},
sessionUpdate: (params: SessionUpdateParams) => {
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
const subscription = new ACPEvent.Subscription({ sdk, connection, session })
return { connection, replies, requests, sdk, session, subscription, updates }
}
async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") {
await Effect.runPromise(session.create({ id: sessionId, cwd }))
}
async function createKnownTextPart(
session: ACPSession.Interface,
sessionId: string,
messageId: string,
partId: string,
) {
await Effect.runPromise(
session.recordPartMetadata({
sessionId,
messageId,
partId,
partType: "text",
role: "assistant",
}),
)
}
function permissionAsked(
sessionID: string,
id: string,
input: {
permission?: string
metadata?: Record<string, unknown>
tool?: { messageID: string; callID: string }
} = {},
) {
return {
id: `evt_${id}`,
type: "permission.asked",
properties: {
id,
sessionID,
permission: input.permission ?? "bash",
patterns: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
always: [],
...(input.tool ? { tool: input.tool } : {}),
},
} as PermissionEvent
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
type: "message.part.delta",
properties: {
sessionID,
messageID,
partID,
field: "text",
delta,
},
} as Event
}
function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
return updates
.filter((item) => item.sessionId === sessionId)
.map((item) => item.update)
.filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
return update.sessionUpdate === "agent_message_chunk"
})
.map((update) => (update.content.type === "text" ? update.content.text : ""))
.join("")
}
describe("acp permissions", () => {
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
await pollUntil(() => harness.replies.length === 1, "permission was never replied")
expect(harness.requests[0]).toMatchObject({
sessionId: "ses_a",
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "bash",
rawInput: { command: "printf hello" },
kind: "execute",
locations: [],
},
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(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
})
it("rejects non-selected outcomes", async () => {
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
})
it("rejects when requestPermission fails", async () => {
const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
})
it("does not let a blocked session A permission block session B message updates", async () => {
let releasePermission: (() => void) | undefined
const blocked = new Promise<RequestPermissionResponse>((resolve) => {
releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() => blocked)
await createSession(harness.session, "ses_a")
await createSession(harness.session, "ses_b")
await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
expect(harness.replies).toHaveLength(0)
releasePermission?.()
await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
})
it("serializes permission requests per session", async () => {
let releaseFirst: (() => void) | undefined
const first = new Promise<RequestPermissionResponse>((resolve) => {
releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() =>
harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
)
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
releaseFirst?.()
await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
import { describe, expect } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import * as ACPError from "@/acp/error"
import * as ACPSession from "@/acp/session"
import { ModelID, ProviderID } from "@/provider/schema"
import { testEffect } from "../lib/effect"
const sessionTest = testEffect(ACPSession.defaultLayer)
const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
})
const mcpServer: McpServer = {
name: "local-tools",
command: "node",
args: ["server.js"],
env: [],
}
describe("acp session state", () => {
sessionTest.effect("creates and retrieves session state", () =>
Effect.gen(function* () {
const createdAt = new Date("2026-05-25T00:00:00.000Z")
const created = yield* ACPSession.Service.use((session) =>
session.create({
id: "ses_1",
cwd: "/workspace",
mcpServers: [mcpServer],
createdAt,
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
}),
)
const loaded = yield* ACPSession.Service.use((session) => session.get("ses_1"))
expect(created).toMatchObject({
id: "ses_1",
cwd: "/workspace",
mcpServers: [mcpServer],
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
})
expect(loaded.createdAt).toEqual(createdAt)
expect(loaded.knownParts.size).toBe(0)
}),
)
sessionTest.effect("fails required lookups with typed SessionNotFound", () =>
Effect.gen(function* () {
const error = yield* ACPSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
expect(error).toBeInstanceOf(ACPError.SessionNotFoundError)
expect(error.sessionId).toBe("ses_missing")
}),
)
sessionTest.effect("tryGet lets event routing ignore unknown sessions", () =>
Effect.gen(function* () {
const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_missing"))
const missingPart = yield* ACPSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }),
)
expect(missing).toBeUndefined()
expect(missingPart).toBeUndefined()
}),
)
sessionTest.effect("updates selected model while preserving session identity and inputs", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) =>
session.create({
id: "ses_model",
cwd: "/workspace",
mcpServers: [mcpServer],
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
}),
)
const updated = yield* ACPSession.Service.use((session) =>
session.setModel("ses_model", model("openai", "gpt-5")),
)
expect(updated.id).toBe("ses_model")
expect(updated.cwd).toBe("/workspace")
expect(updated.mcpServers).toEqual([mcpServer])
expect(updated.model).toEqual(model("openai", "gpt-5"))
expect(updated.variant).toBe("high")
expect(updated.modeId).toBe("build")
}),
)
sessionTest.effect("updates selected variant and mode independently", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) =>
session.load({
id: "ses_config",
cwd: "/workspace",
model: model("anthropic", "claude-sonnet"),
variant: "low",
modeId: "plan",
}),
)
yield* ACPSession.Service.use((session) => session.setVariant("ses_config", "high"))
expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
yield* ACPSession.Service.use((session) => session.setMode("ses_config", "build"))
expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
}),
)
sessionTest.effect("records known message part metadata for delta routing", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
const metadata = yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_parts",
messageId: "msg_1",
partId: "part_1",
toolCallId: "tool_1",
metadata: { output: "first chunk" },
}),
)
const routed = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }),
)
expect(metadata).toEqual({
messageId: "msg_1",
partId: "part_1",
toolCallId: "tool_1",
metadata: { output: "first chunk" },
})
expect(routed).toEqual(metadata)
}),
)
sessionTest.effect("keeps repeated part ids distinct across messages", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_duplicate_parts",
messageId: "msg_1",
partId: "part_1",
metadata: { output: "from first message" },
}),
)
yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_duplicate_parts",
messageId: "msg_2",
partId: "part_1",
metadata: { output: "from second message" },
}),
)
const first = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }),
)
const second = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }),
)
expect(first?.metadata).toEqual({ output: "from first message" })
expect(second?.metadata).toEqual({ output: "from second message" })
}),
)
sessionTest.effect("removing a session clears its known part metadata", () =>
Effect.gen(function* () {
yield* ACPSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
)
const removed = yield* ACPSession.Service.use((session) => session.remove("ses_remove"))
const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_remove"))
const missingPart = yield* ACPSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
)
expect(removed?.knownParts.size).toBe(1)
expect(missing).toBeUndefined()
expect(missingPart).toBeUndefined()
}),
)
})

View File

@@ -0,0 +1,169 @@
import { describe, expect, test } from "bun:test"
import {
completedToolContent,
completedToolRawOutput,
extractImageAttachments,
imageContents,
shellOutputSnapshot,
toLocations,
toToolKind,
} from "../../src/acp/tool"
describe("acp tool conversion", () => {
test("maps OpenCode tool ids to ACP tool kinds", () => {
expect(toToolKind("bash")).toBe("execute")
expect(toToolKind("shell")).toBe("execute")
expect(toToolKind("webfetch")).toBe("fetch")
expect(toToolKind("edit")).toBe("edit")
expect(toToolKind("patch")).toBe("edit")
expect(toToolKind("write")).toBe("edit")
expect(toToolKind("grep")).toBe("search")
expect(toToolKind("glob")).toBe("search")
expect(toToolKind("repo_clone")).toBe("search")
expect(toToolKind("repo_overview")).toBe("search")
expect(toToolKind("context7_resolve_library_id")).toBe("search")
expect(toToolKind("context7_get_library_docs")).toBe("search")
expect(toToolKind("read")).toBe("read")
expect(toToolKind("custom_tool")).toBe("other")
})
test("extracts file locations from tool input", () => {
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
expect(toLocations("repo_clone", { path: "/repo" })).toEqual([{ path: "/repo" }])
expect(toLocations("repo_overview", { path: "/repo" })).toEqual([{ path: "/repo" }])
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
test("builds completed content with text, edit diffs, and image attachments", () => {
const image = Buffer.from("image-data").toString("base64")
expect(
completedToolContent("edit", {
status: "completed",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
output: "edited /tmp/file.ts",
attachments: [
{
type: "file",
mime: "image/png",
filename: "image.png",
url: `data:image/png;base64,${image}`,
},
{
type: "file",
mime: "text/plain",
filename: "note.txt",
url: "data:text/plain;base64,bm90ZQ==",
},
],
}),
).toEqual([
{
type: "content",
content: { type: "text", text: "edited /tmp/file.ts" },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
{
type: "content",
content: { type: "image", mimeType: "image/png", data: image },
},
])
})
test("omits edit diffs until old and new text fields exist", () => {
expect(
completedToolContent("write", {
status: "completed",
input: {
filePath: "/tmp/file.ts",
content: "created",
},
output: "wrote /tmp/file.ts",
}),
).toEqual([
{
type: "content",
content: { type: "text", text: "wrote /tmp/file.ts" },
},
])
})
test("builds completed raw output with optional metadata and attachments", () => {
const attachments = [
{
type: "file",
mime: "image/jpeg",
filename: "photo.jpg",
url: "data:image/jpeg;base64,AAAA",
},
]
expect(
completedToolRawOutput({
status: "completed",
input: {},
output: "done",
metadata: { exit: 0 },
attachments,
}),
).toEqual({
output: "done",
metadata: { exit: 0 },
attachments,
})
expect(
completedToolRawOutput({
status: "completed",
input: {},
output: "done",
}),
).toEqual({ output: "done" })
})
test("extracts image attachments only from data URLs", () => {
const attachments = [
{
mime: "image/webp",
url: "data:image/webp;charset=utf-8;base64,AAAA",
},
{
mime: "image/png",
url: "https://example.com/image.png",
},
{
mime: "text/plain",
url: "data:text/plain;base64,BBBB",
},
]
expect(extractImageAttachments(attachments)).toEqual([{ mimeType: "image/webp", data: "AAAA" }])
expect(imageContents(attachments)).toEqual([
{
type: "content",
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
},
])
})
test("reads shell output snapshot from string metadata output", () => {
expect(shellOutputSnapshot({ metadata: { output: "line 1\nline 2" } })).toBe("line 1\nline 2")
expect(shellOutputSnapshot({ metadata: { output: 42 } })).toBeUndefined()
expect(shellOutputSnapshot({ metadata: undefined })).toBeUndefined()
})
})

View File

@@ -0,0 +1,314 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { UsageService } from "@/acp/usage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
const assistant = (
input: Partial<UsageService.AssistantMessage> & Pick<UsageService.AssistantMessage, "cost">,
): UsageService.SessionMessage => ({
info: {
role: "assistant",
providerID: "anthropic",
modelID: "claude-sonnet",
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 0, write: 0 },
},
...input,
},
})
const user = (): UsageService.SessionMessage => ({
info: { role: "user" },
})
const assistantWithoutProvider = (): UsageService.SessionMessage => ({
info: {
role: "assistant",
modelID: "claude-sonnet",
cost: 1,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
})
const model = (providerID: ProviderID, modelID: ModelID, context: number): Provider.Model => ({
id: modelID,
providerID,
api: {
id: modelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: modelID,
family: "test",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
})
const providers = (context = 128_000): Record<ProviderID, Provider.Info> => {
const providerID = ProviderID.make("anthropic")
const modelID = ModelID.make("claude-sonnet")
return {
[providerID]: {
id: providerID,
name: "Anthropic",
source: "config",
env: [],
options: {},
models: {
[modelID]: model(providerID, modelID, context),
},
},
}
}
const fakeLayer = (input: {
readonly messages?: Effect.Effect<readonly UsageService.SessionMessage[], unknown>
readonly providers?: (directory: string) => Effect.Effect<Record<ProviderID, Provider.Info>, unknown>
}) =>
UsageService.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(
UsageService.MessageLoader,
UsageService.MessageLoader.of({
messages: () => input.messages ?? Effect.succeed([]),
}),
),
Layer.succeed(
UsageService.ContextLimitLoader,
UsageService.ContextLimitLoader.of({
providers: input.providers ?? (() => Effect.succeed(providers())),
}),
),
),
),
)
const connection = (updates: SessionNotification[]) => ({
sessionUpdate(params: SessionNotification) {
updates.push(params)
return Promise.resolve()
},
})
describe("acp usage", () => {
test("builds ACP Usage from assistant token shape", () => {
expect(
UsageService.buildUsage({
cost: 0.02,
tokens: {
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
},
}),
).toEqual({
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
})
})
test("omits optional token fields when they are zero", () => {
expect(
UsageService.buildUsage({
cost: 0,
tokens: {
input: 3,
output: 4,
reasoning: 0,
cache: { read: 0, write: 0 },
},
}),
).toEqual({
inputTokens: 3,
outputTokens: 4,
totalTokens: 7,
})
})
test("finds the latest assistant message", () => {
expect(
UsageService.latestAssistantMessage([assistant({ cost: 1, modelID: "older" }), user(), assistant({ cost: 2 })]),
).toMatchObject({ cost: 2 })
})
test("calculates total session cost from assistant messages", () => {
expect(UsageService.totalSessionCost([assistant({ cost: 1.25 }), user(), assistant({ cost: 2.5 })])).toBe(3.75)
})
it.effect("loads context limits from providers and caches by directory/provider/model", () => {
const calls: string[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
const first = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderID.make("anthropic"),
modelID: ModelID.make("claude-sonnet"),
})
const second = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderID.make("anthropic"),
modelID: ModelID.make("claude-sonnet"),
})
expect(first).toBe(200_000)
expect(second).toBe(200_000)
expect(calls).toEqual(["/workspace"])
}).pipe(
Effect.provide(
fakeLayer({
providers: (directory) =>
Effect.sync(() => {
calls.push(directory)
return providers(200_000)
}),
}),
),
)
})
it.effect("sends ACP usage_update with context size and cumulative assistant cost", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([
{
sessionId: "ses_1",
update: {
sessionUpdate: "usage_update",
used: 15,
size: 128_000,
cost: { amount: 3, currency: "USD" },
},
},
])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([
assistant({ cost: 1 }),
assistant({
cost: 2,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 5, write: 0 },
},
}),
]),
}),
),
)
})
it.effect("skips usage update when messages cannot be fetched", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(Effect.provide(fakeLayer({ messages: Effect.fail(new Error("boom")) })))
})
it.effect("skips usage update when no assistant message exists", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(Effect.provide(fakeLayer({ messages: Effect.succeed([user()]) })))
})
it.effect("skips usage update when assistant message has no provider or model", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([assistantWithoutProvider()]),
}),
),
)
})
it.effect("skips usage update when context size is unknown", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([assistant({ cost: 1, providerID: "missing" })]),
}),
),
)
})
})