feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
229
packages/opencode/test/acp/config-option.test.ts
Normal file
229
packages/opencode/test/acp/config-option.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
201
packages/opencode/test/acp/content.test.ts
Normal file
201
packages/opencode/test/acp/content.test.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
186
packages/opencode/test/acp/directory.test.ts
Normal file
186
packages/opencode/test/acp/directory.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Directory } from "@/acp/directory"
|
||||
import { Command } from "@/command"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
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: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
|
||||
id: ModelV2.ID.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 = ProviderV2.ID.make(`provider-${directory}`)
|
||||
const modelID = ModelV2.ID.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" },
|
||||
}),
|
||||
[ModelV2.ID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
|
||||
},
|
||||
},
|
||||
} satisfies Record<ProviderV2.ID, 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: ModelV2.ID.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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
67
packages/opencode/test/acp/error.test.ts
Normal file
67
packages/opencode/test/acp/error.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
743
packages/opencode/test/acp/event.test.ts
Normal file
743
packages/opencode/test/acp/event.test.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
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"] = [],
|
||||
options: {
|
||||
readonly tool?: string
|
||||
readonly input?: Record<string, unknown>
|
||||
readonly metadata?: Record<string, unknown>
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
id: `part_${callID}`,
|
||||
sessionID,
|
||||
messageID: `msg_${callID}`,
|
||||
type: "tool",
|
||||
callID,
|
||||
tool: options.tool ?? "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: options.input ?? { cmd: "printf done" },
|
||||
output,
|
||||
title: "bash",
|
||||
metadata: options.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("includes available input in the synthetic pending tool call", async () => {
|
||||
const harness = createHarness()
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_pending_input", cwd: "/workspace" }))
|
||||
|
||||
await harness.subscription.handle(
|
||||
toolUpdated({
|
||||
id: "part_call_read",
|
||||
sessionID: "ses_pending_input",
|
||||
messageID: "msg_call_read",
|
||||
type: "tool",
|
||||
callID: "call_read",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { filePath: "/workspace/file.ts" },
|
||||
title: "Read file.ts",
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
} satisfies ToolPart),
|
||||
)
|
||||
|
||||
expect(harness.updates[0]?.update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "call_read",
|
||||
status: "pending",
|
||||
title: "Read file.ts",
|
||||
kind: "read",
|
||||
rawInput: { filePath: "/workspace/file.ts" },
|
||||
locations: [{ path: "/workspace/file.ts" }],
|
||||
})
|
||||
})
|
||||
|
||||
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 clean read display content and preserves rawOutput", async () => {
|
||||
const harness = createHarness()
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_read", cwd: "/workspace" }))
|
||||
const output = [
|
||||
"<path>/workspace/file.ts</path>",
|
||||
"<type>file</type>",
|
||||
"<content>",
|
||||
"1: import { value } from './value'",
|
||||
"2: export { value }",
|
||||
"",
|
||||
"(End of file - total 2 lines)",
|
||||
"</content>",
|
||||
].join("\n")
|
||||
const metadata = {
|
||||
display: {
|
||||
type: "file",
|
||||
path: "/workspace/file.ts",
|
||||
text: "import { value } from './value'\nexport { value }",
|
||||
lineStart: 1,
|
||||
lineEnd: 2,
|
||||
totalLines: 2,
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
|
||||
await harness.subscription.handle(
|
||||
toolUpdated(
|
||||
completedTool("ses_read", "call_read", output, [], {
|
||||
tool: "read",
|
||||
input: { filePath: "/workspace/file.ts" },
|
||||
metadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(harness.updates.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call_read",
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "import { value } from './value'\nexport { value }" },
|
||||
},
|
||||
],
|
||||
rawOutput: { output, metadata },
|
||||
})
|
||||
})
|
||||
|
||||
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 } },
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
273
packages/opencode/test/acp/permission.test.ts
Normal file
273
packages/opencode/test/acp/permission.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
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("forwards external_directory metadata and locations to requestPermission", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_a", "perm_external", {
|
||||
permission: "external_directory",
|
||||
metadata: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "external_directory permission was never replied")
|
||||
|
||||
expect(harness.requests[0]).toMatchObject({
|
||||
sessionId: "ses_a",
|
||||
toolCall: {
|
||||
toolCallId: "call_1",
|
||||
status: "pending",
|
||||
title: "external_directory",
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
locations: [{ path: "/tmp/outside" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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"],
|
||||
])
|
||||
})
|
||||
})
|
||||
1174
packages/opencode/test/acp/service-session.test.ts
Normal file
1174
packages/opencode/test/acp/service-session.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
200
packages/opencode/test/acp/session.test.ts
Normal file
200
packages/opencode/test/acp/session.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import * as ACPError from "@/acp/error"
|
||||
import * as ACPSession from "@/acp/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const sessionTest = testEffect(ACPSession.defaultLayer)
|
||||
|
||||
const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
|
||||
providerID: ProviderV2.ID.make(providerID),
|
||||
modelID: ModelV2.ID.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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
210
packages/opencode/test/acp/tool.test.ts
Normal file
210
packages/opencode/test/acp/tool.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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("apply_patch")).toBe("edit")
|
||||
expect(toToolKind("patch")).toBe("edit")
|
||||
expect(toToolKind("write")).toBe("edit")
|
||||
expect(toToolKind("grep")).toBe("search")
|
||||
expect(toToolKind("glob")).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("task")).toBe("think")
|
||||
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("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
|
||||
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
|
||||
{ path: "/tmp/outside" },
|
||||
])
|
||||
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("uses clean read display text for completed content", () => {
|
||||
const output = [
|
||||
"<path>/tmp/file.ts</path>",
|
||||
"<type>file</type>",
|
||||
"<content>",
|
||||
"7: first",
|
||||
"8: second",
|
||||
"",
|
||||
"(End of file - total 8 lines)",
|
||||
"</content>",
|
||||
].join("\n")
|
||||
const state = {
|
||||
status: "completed" as const,
|
||||
input: { filePath: "/tmp/file.ts" },
|
||||
output,
|
||||
metadata: {
|
||||
display: {
|
||||
type: "file",
|
||||
path: "/tmp/file.ts",
|
||||
text: "first\nsecond",
|
||||
lineStart: 7,
|
||||
lineEnd: 8,
|
||||
totalLines: 8,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(completedToolContent("read", state)).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "first\nsecond" },
|
||||
},
|
||||
])
|
||||
expect(completedToolRawOutput(state)).toEqual({
|
||||
output,
|
||||
metadata: state.metadata,
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
315
packages/opencode/test/acp/usage.test.ts
Normal file
315
packages/opencode/test/acp/usage.test.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { UsageService } from "@/acp/usage"
|
||||
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: ProviderV2.ID, modelID: ModelV2.ID, 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<ProviderV2.ID, Provider.Info> => {
|
||||
const providerID = ProviderV2.ID.make("anthropic")
|
||||
const modelID = ModelV2.ID.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<ProviderV2.ID, 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: ProviderV2.ID.make("anthropic"),
|
||||
modelID: ModelV2.ID.make("claude-sonnet"),
|
||||
})
|
||||
const second = yield* usage.contextLimit({
|
||||
directory: "/workspace",
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
modelID: ModelV2.ID.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" })]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user