Compare commits

...
Author SHA1 Message Date
Kit Langton 5bf398d531 refactor(tui): share clamp between tab pulse and fade-in text 2026-10-03 12:10:39 -07:00
opencode-agent[bot] d1f8f5b35e chore: update nix node_modules hashes 2026-10-03 18:57:44 +00:00
Kit Langton 334f7a3510 fix(core): keep the first PTY output after spawn (#52960) 2026-10-03 11:51:01 -07:00
opencode-agent[bot] f02e184ba4 chore: update nix node_modules hashes 2026-10-03 14:32:42 +00:00
James Long 5ad0786536 fix(core): never let persistent PTY handoff break a server restart (#52573) 2026-10-03 10:23:31 -04:00
opencode-agent[bot] d8a3962923 chore(core): refresh bundled models.dev snapshot 2026-10-03 13:46:03 +00:00
Simon Klee 40679546d4 fix(tui): keep transcript rows stable when history is prepended (#52824)
Session rows have no id, so reconcile matched them by position. Each page of older history shifted every row into a different store object and remounted the visible transcript. Newly mounted markdown is blank until highlighting finishes, so the transcript blinked once per page. This was most visible with Low verbosity, where opening a session loads several pages to complete the oldest group.
2026-10-03 06:09:37 +00:00
Daniel Chen 12810e84d3 docs(www): add Fledge Alpha Free to Console models (#52896) 2026-10-02 21:59:24 -07:00
Aiden Cline 3e1aa1e58c fix(server): return 404 when a location folder is missing (#52668) 2026-10-02 23:43:56 -05:00
Aiden Cline 7ced9326e6 fix(ai): preserve Mistral and Cohere system parts (#52890) 2026-10-02 23:36:36 -05:00
Aiden Cline d1ff6f90cf fix(ai): preserve Gemini system text parts (#52888) 2026-10-02 23:04:49 -05:00
James Long d9d094a543 fix(tui): keep question form highlights on the raised surface (#52872) 2026-10-02 20:34:12 -04:00
Kit Langton d113a40f55 fix(core): rename legacy provider in top-level model (#51901) 2026-10-02 16:02:13 -07:00
Kit Langton 54956aac22 chore: enable noUnusedLocals in ai and core (#52858) 2026-10-02 22:45:27 +00:00
Kit Langton c1536734ce chore(cli): enable noUnusedLocals (#52857) 2026-10-02 22:45:05 +00:00
Kit Langton cf7fcfad0a chore(app): enable noUnusedLocals (#52856) 2026-10-02 22:44:02 +00:00
Kit Langton 17955a1100 refactor(core): remove duplicate v1 config migration (#51889) 2026-10-02 15:40:41 -07:00
Kit Langton c0c9c69a27 chore(tui): enable noUnusedLocals (#52851) 2026-10-02 22:36:03 +00:00
Kit Langton eb1a9ea4d9 chore: enable noUnusedLocals in schema, server, sdk and smaller packages (#52850) 2026-10-02 15:23:59 -07:00
Kit Langton 680bca3183 chore: enable noUnusedLocals in already-clean packages (#52849) 2026-10-02 15:23:50 -07:00
Kit Langton 22f81eed0b test(tui): drop title shimmer animation timing tests (#52848) 2026-10-02 15:23:31 -07:00
Aiden Cline 421a04f5fd fix(core): retry transient MCP connect failures (#52614) 2026-10-02 17:22:25 -05:00
Jérôme BenoitandTest User ec541abbcd chore(nix): run the nix eval workflow on v2 (#52143)
Co-authored-by: Test User <test@test.com>
2026-10-02 16:52:46 -05:00
Kit Langton 4c63a35dbe refactor(schema): drop unused FileAttachment re-export from SessionEvent (#52842) 2026-10-02 21:35:12 +00:00
Kit Langton 0e4dd7bfe9 refactor(ai): remove unused declarations in OpenAI chat protocol (#52840) 2026-10-02 21:33:58 +00:00
Kit Langton e28a624b8b refactor(core): remove unused git strategy in worktree layer (#52841) 2026-10-02 21:28:27 +00:00
Kit Langton 9277761af6 refactor(schema): remove unused QuestionV1 contract (#52838) 2026-10-02 21:28:13 +00:00
Kit Langton 0bef0eab67 refactor(core): remove legacy id prefix table (#52810) 2026-10-02 18:40:26 +00:00
Shoubhit Dash 66d15a7352 refactor(acp): split module seams and drop dead code (#52803) 2026-10-03 00:04:06 +05:30
Kit Langton 9f8551ab6b refactor(core): require job ids from their subject (#52806) 2026-10-02 18:27:13 +00:00
Kit Langton 8bcda4c718 refactor(core): name tool output files in ToolOutput (#52808) 2026-10-02 18:24:57 +00:00
Kit Langton ccd2aca07a test(core): match Code Mode description after allSettled change (#52812) 2026-10-02 18:14:27 +00:00
1391c0d52c docs(core): recommend settled results for independent calls (#52798)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-10-02 12:26:05 -05:00
Shoubhit Dash ec90ad50d3 fix(acp): settle permissions replied elsewhere and type request failures (#52774) 2026-10-02 22:43:32 +05:30
Shoubhit Dash 2a01ed60b1 perf(core): keep unchanged messages in request shaping (#52784) 2026-10-02 22:12:21 +05:30
Shoubhit Dash 80caae8ad1 test(acp): keep only spec and essential tests (#52775) 2026-10-02 22:01:49 +05:30
220 changed files with 2960 additions and 6720 deletions

No files matched your search

+2 -2
View File
@@ -2,9 +2,9 @@ name: nix-eval
on:
push:
branches: [dev]
branches: [dev, v2]
pull_request:
branches: [dev]
branches: [dev, v2]
workflow_dispatch:
concurrency:
+10 -12
View File
@@ -122,7 +122,7 @@
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/pty": "0.2.0",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -355,7 +355,7 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/pty": "0.2.0",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
@@ -365,7 +365,7 @@
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"bun-pty": "0.4.8",
"bun-pty": "0.4.9",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -2167,19 +2167,17 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.13", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.13", "@opencode-ai/pty-darwin-x64": "0.1.13", "@opencode-ai/pty-linux-arm64-gnu": "0.1.13", "@opencode-ai/pty-linux-arm64-musl": "0.1.13", "@opencode-ai/pty-linux-x64-gnu": "0.1.13", "@opencode-ai/pty-linux-x64-musl": "0.1.13" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-WPCN8h8HaZhhUcrMG0zu+4D9vco0EZiEg/gCF1K3JPRN6UsHMiXq1HVIy5IlyfcoyjfViRmQmXYE4AuU3laBjA=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.2.0", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.2.0", "@opencode-ai/pty-darwin-x64": "0.2.0", "@opencode-ai/pty-linux-arm64-gnu": "0.2.0", "@opencode-ai/pty-linux-arm64-musl": "0.2.0", "@opencode-ai/pty-linux-x64-gnu": "0.2.0", "@opencode-ai/pty-linux-x64-musl": "0.2.0" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-pV86urAwinpwFXX8AJlOf8S9CVJhGsV+11I/J6TcxMW0c7u6LgeODzdj/BVBC6jUhsG2aKDxg8rACMcDCy3HiA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2y6xrktb2J7rRk+mzAJHA8cCbWhC4Lo2zJ66t9ad59qFhL5nzAPfpEOwnyvvFqhxebHNvL08Mp64M9IHnM0aiA=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XVQwK9+KgVGYunCYPCCBf1Or0z6zSkzjgfdJd7dEe/LOFg5vmMkOfSB9dCXnoRCWGviWYk7xd07iFIFOgyj5Ig=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-I124aSYBBjpGZnYExHfIajkvVK1FiK+//OJBGdqqFp5pas2Oruq4O8tv+pMoxomZIYh2ce/QhOOYLHRwXsthTg=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TPVGCQk5E65IY3ipcpd17rwKehdvXcXEYRhhGzok/6Dske69ei2S+ERz7NXZ8cKFAvbiiT771HHM+XyVYAl+7A=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iH6/liY7xN1OXVD9eGzdH11BVGvnpPsH5Z1Unz0Pn9EzkPFf28oDNKNyeXV0xIAP53oiYp8gpRq2PADNwbVsTg=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GhdrmbUzxGHRfWvt1qutgVD4HLb7aSBsgWGjFsc6k8HjO5ZbdaC3ScRyd512YAAG5KAROLKZ6+0SEBsm5xFYlA=="],
"@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
@@ -3549,7 +3547,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-pty": ["bun-pty@0.4.9", "", {}, "sha512-IUF/B3FANo8vIQ775Zt7Er7lphMpYMhLkes45am2WE8FaVI7KRYtj1rQwliZ18b6GpNzBNWcl7sz9QT5wDFBeQ=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
"x86_64-linux": "sha256-onbxt1PCyU4Nh14S5gIeLH20OtbQ7v2ipRq1zqZkLWs=",
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
}
}
+9 -1
View File
@@ -124,7 +124,15 @@ const fromRequest = Effect.fn("CohereChat.fromRequest")(function* (request: LLMR
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
const flattened = ProviderShared.flattenToolRequest(request)
const messages: (typeof Message.Type)[] = request.system.length
? [{ role: "system", content: ProviderShared.joinText(request.system) }]
? [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
: []
for (const message of flattened.request.messages) {
if (message.role === "system") {
+1 -1
View File
@@ -475,7 +475,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
request.system.length === 0 ? undefined : { parts: request.system.map((part) => ({ text: part.text })) },
tools: hasTools
? [
{
+15 -2
View File
@@ -68,7 +68,10 @@ const MistralAssistantToolCall = Schema.Struct({
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
const MistralMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(MistralTextContent)]),
}),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
@@ -335,7 +338,17 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const normalizeID = MistralToolID.normalizer(request)
const messages: MistralMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
request.system.length === 0
? []
: [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
for (const message of request.messages) {
if (message.role === "system") {
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
-7
View File
@@ -14,7 +14,6 @@ import {
ProviderInternalError,
UnknownProviderError,
Usage,
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type LLMRequest,
@@ -46,12 +45,6 @@ const OpenAIChatCacheControl = Schema.Struct({
})
type OpenAIChatCacheControl = Schema.Schema.Type<typeof OpenAIChatCacheControl>
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
})
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: Schema.Struct({
-1
View File
@@ -14,7 +14,6 @@ import * as GoogleVertexChat from "../src/providers/google-vertex-chat.js"
import * as GoogleVertexMessages from "../src/providers/google-vertex-messages.js"
import * as GoogleVertexResponses from "../src/providers/google-vertex-responses.js"
import * as OpenAI from "../src/providers/openai.js"
import * as OpenAICompatible from "../src/providers/openai-compatible.js"
import * as OpenRouter from "../src/providers/openrouter.js"
import * as XAI from "../src/providers/xai.js"
@@ -6,7 +6,7 @@
"provider:cohere"
],
"name": "cohere/continues-a-native-tool-call",
"recordedAt": "2026-10-02T02:36:16.143Z"
"recordedAt": "2026-10-03T04:09:51.600Z"
},
"interactions": [
{
@@ -17,14 +17,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message-start\ndata: {\"id\":\"a57af21c-0496-4123-8e83-bcc6abb9bcae\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" about\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" access\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" instructions\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" say\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" use\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" exactly\"}}}}\n\nevent: conteLine truncated
"body": "event: message-start\ndata: {\"id\":\"d6c0d412-926c-42ed-b152-043404f8c33c\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" available\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" specifically\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" mentioned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" as\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: conteLine truncated
}
},
{
@@ -35,14 +35,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking about the weather in Paris. I have access to a get_weather function that takes a city parameter. The instructions say to use the get_weather tool exactly once, and then reply exactly \\\"Paris is sunny\\\" after getting the tool result.\\n\\nLet me call the get_weather function with \\\"Paris\\\" as the city parameter.\"}],\"tool_calls\":[{\"id\":\"get_weather_mdhvw7kbf1e3\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_mdhvw7kbf1e3\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking for the weather in Paris. I have a get_weather function available that takes a city parameter. The user specifically mentioned \\\"Paris\\\" as the city. I need to use the get_weather tool exactly once as per the policies, and I have all the required parameters (city = \\\"Paris\\\").\\n\\nLet me call the function with the city parameter set to \\\"Paris\\\".\"}],\"tool_calls\":[{\"id\":\"get_weather_vfp5ryredwrg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_vfp5ryredwrg\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message-start\ndata: {\"id\":\"fd1ac100-b2c5-42f4-ae1c-6de65aa5ef6a\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" has\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" with\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" of\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" According\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" instructions\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" after\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" getting\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" need\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" reply\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" exactly\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: cLine truncated
"body": "event: message-start\ndata: {\"id\":\"0c6cba0e-442d-4e28-96b2-c3cd6ff3b22d\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" returned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" data\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" (\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"presumably\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Celsius\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\")\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Condition\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n\\nI\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" should\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" present\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" this\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" information\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\Line truncated
}
}
]
@@ -2,9 +2,16 @@
"version": 1,
"metadata": {
"model": "mistral-small-latest",
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
"tags": [
"prefix:mistral-chat",
"provider:mistral",
"protocol:mistral-chat",
"tool",
"tool-loop",
"usage"
],
"name": "mistral-chat/drives-a-tool-loop",
"recordedAt": "2026-08-30T17:18:49.552Z"
"recordedAt": "2026-10-03T04:09:49.878Z"
},
"interactions": [
{
@@ -15,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
"body": "data: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":120,\"total_tokens\":132,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghij\"}\n\ndata: [DONE]\n\n"
}
},
{
@@ -33,14 +40,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"EwgHkPRLW\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
"body": "data: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with a\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature of 1\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"8°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"total_tokens\":84,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: [DONE]\n\n"
}
}
]
@@ -1,6 +1,6 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, SystemPart } from "../../src/index.js"
import { Cohere } from "../../src/providers/cohere.js"
import { LLMClient } from "../../src/route.js"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios.js"
@@ -55,12 +55,20 @@ recorded.effect(
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
LLMRequest.update(
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
{
system: [
SystemPart.make("Use the get_weather tool exactly once."),
SystemPart.make("After the tool result, reply exactly: Paris is sunny."),
],
},
),
)
expectWeatherToolLoop(events)
expect(events.some(LLMEvent.is.toolInputDelta)).toBe(true)
+9 -3
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMClient, LLMEvent, Media, Message, isRetryable } from "../../src/index.js"
import { LLM, LLMClient, LLMEvent, Media, Message, SystemPart, isRetryable } from "../../src/index.js"
import { Cohere } from "../../src/providers/cohere.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -23,7 +23,7 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
const prepared = yield* compileRequest(
LLM.request({
model: cohere.model("command-a-reasoning-08-2025"),
system: "Be concise.",
system: [SystemPart.make("Be concise.\nKeep this newline."), SystemPart.make("Second instructions.")],
messages: [
Message.user("Lookup Paris"),
Message.assistant([{ type: "tool-call", id: "lookup-1", name: "lookup", input: { city: "Paris" } }]),
@@ -44,7 +44,13 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
thinking: { type: "enabled", token_budget: 128 },
tool_choice: "REQUIRED",
messages: [
{ role: "system", content: "Be concise." },
{
role: "system",
content: [
{ type: "text", text: "Be concise.\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ role: "user", content: [{ type: "text", text: "Lookup Paris" }] },
{
role: "assistant",
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent, Message, ToolDefinition, Media } from "../../src/index.js"
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
import { Mistral } from "../../src/providers/index.js"
import { MistralChat } from "../../src/protocols/index.js"
import { LLMClient } from "../../src/route.js"
@@ -41,7 +41,7 @@ describe("Mistral Chat", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
system: "Initial",
system: [SystemPart.make("Initial\nKeep this newline."), SystemPart.make("Second instructions.")],
messages: [
Message.system("Updated"),
Message.user([
@@ -105,7 +105,13 @@ describe("Mistral Chat", () => {
reasoning_effort: "high",
})
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
{ role: "system", content: "Initial" },
{
role: "system",
content: [
{ type: "text", text: "Initial\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
{
role: "user",
@@ -1,7 +1,7 @@
import { configure } from "@opencode/ai/providers/mistral"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, Message, SystemPart, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
@@ -97,7 +97,10 @@ describe("Mistral recorded", () => {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
system: "Call lookup_weather exactly once with Paris.",
system: [
SystemPart.make("Call lookup_weather exactly once with Paris."),
SystemPart.make("After the tool result, describe the weather briefly."),
],
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
@@ -1,7 +1,6 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
mapVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
@@ -242,7 +242,7 @@ test("keeps an older answer selected while scrolling within the interaction buff
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
await setupTimeline(page, {
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
settings: { shellToolPartsExpanded: false },
cpuRate: 4,
@@ -15,7 +15,6 @@ import {
partUpdated,
renderedPartID,
setupTimeline,
shell,
status,
textPart,
toolPart,
@@ -1,4 +1,4 @@
import type { CDPSession, Page } from "@playwright/test"
import type { Page } from "@playwright/test"
import path from "node:path"
import { mkdir, writeFile } from "node:fs/promises"
+1 -8
View File
@@ -30,14 +30,7 @@ import type { Upload } from "../attachments/uploads"
import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { Skill } from "@opencode/schema/skill"
import type {
ComposerAttachment,
ComposerComment,
ComposerOption,
ComposerPersistedState,
ComposerPrompt,
ComposerSuggestion,
} from "../types"
import type { ComposerAttachment, ComposerComment, ComposerOption, ComposerPrompt, ComposerSuggestion } from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
@@ -35,7 +35,6 @@ import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
import type { ModelSelection } from "@/providers/models/selection"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { useTabs } from "@/shell/tabs/tabs"
+1 -160
View File
@@ -2,7 +2,6 @@ import { Component, Show, createMemo, createResource } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Select } from "@opencode/ui/select"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { TimelineDetailControl } from "@/settings/timeline-detail"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
@@ -14,42 +13,12 @@ import {
useSettings,
} from "@/settings/model"
import { formatKeybind } from "@/shell/commands/command"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import {
createAppearanceSettingsController,
createShellOptions,
type AppearanceSettingsController,
type ShellSettingsController,
} from "./controllers"
import { createShellOptions, type ShellSettingsController } from "./controllers"
import "@/settings/settings.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
const fontSettings = {
ui: {
action: "settings-ui-font",
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const AutoApprovePermissionsSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
@@ -183,90 +152,6 @@ const FollowUpBehaviorSetting: Component = () => {
)
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<Select
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && props.controller.scheme.select(option)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</ExternalLink>
</>
}
>
<Select
data-action="settings-theme"
options={props.controller.theme.options()}
current={props.controller.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={props.controller.theme.select}
/>
</SettingsRow>
<FontSetting kind="ui" fonts={props.controller.fonts} />
<FontSetting kind="code" fonts={props.controller.fonts} />
<FontSetting kind="terminal" fonts={props.controller.fonts} />
</SettingsList>
</div>
)
}
const FontSetting: Component<{
kind: "ui" | "code" | "terminal"
fonts: AppearanceSettingsController["fonts"]
}> = (props) => {
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRow title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInput
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRow>
)
}
const LanguageSetting = () => {
const language = useLanguage()
const options = createMemo(() =>
@@ -409,50 +294,6 @@ export const SettingsGeneral: Component = () => {
</div>
)
const NotificationsSection = () => (
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
return (
<>
<div class="settings-tab-header">
@@ -15,7 +15,6 @@ import { displayName } from "@opencode/ui/project-avatar"
import { resolveProjectForSession } from "@/shell/layout/helpers"
import { useExtensionHost } from "@/runtime/extension/host"
import { useExtensionAttachment } from "@/runtime/extension/services"
import { useServer } from "@/runtime/server/current"
import { looksLikeSessionID } from "@/session/search"
export type CommandPaletteEntry = {
@@ -1,23 +1,19 @@
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { SessionError } from "@opencode/schema/session-error"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import { batch, createEffect, onCleanup } from "solid-js"
import type { ServerSDK } from "@/runtime/server/client"
import type { Data } from "@opencode/client/solid"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { ServerConnection } from "@/runtime/server/registry"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { sessionHref } from "@/shell/routes/session"
import { useServer } from "@/runtime/server/current"
const NotificationBase = {
+1 -2
View File
@@ -6,7 +6,7 @@ import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/runti
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useExtensionServers } from "@/runtime/extension/servers"
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate } from "@solidjs/router"
import { usePlatform } from "@/runtime/platform/platform"
import { uuid } from "@/runtime/persistence/uuid"
import { SessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
@@ -80,7 +80,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), TabStorage.Closed, [])
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const memory = createTabMemory(getOwner())
+1 -1
View File
@@ -6,7 +6,7 @@ import { createMutation } from "@tanstack/solid-query"
import { IconButton } from "@opencode/ui/icon-button"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { useServerCtx } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { displayName } from "@opencode/ui/project-avatar"
+1
View File
@@ -13,6 +13,7 @@
"allowJs": true,
"resolveJsonModule": true,
"strict": true,
"noUnusedLocals": true,
"noEmit": false,
"emitDeclarationOnly": true,
"outDir": "node_modules/.ts-dist",
+1 -1
View File
@@ -30,7 +30,7 @@
"@effect/platform-node": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/pty": "0.2.0",
"@opencode/schema": "workspace:*",
"@opencode/server": "workspace:*",
"@opencode/tui": "workspace:*",
+5 -3
View File
@@ -11,6 +11,7 @@ import {
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/effect"
import { Cause, Deferred, Effect, Ref, type Scope } from "effect"
import { ACPCapabilities } from "./capabilities"
import { ACPCatalog } from "./catalog"
import { ACPClient } from "./client"
import { ACPConnection } from "./connection"
@@ -27,7 +28,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
const ready = yield* Deferred.make<ACPService.Interface>()
const handle =
<Params, A>(
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPService.Failure>,
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Failure>,
) =>
(name: string) => {
const handler = Effect.fn(name)(
@@ -35,7 +36,8 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
Effect.catchTags({
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
ACPCatalogNotReadyError: (error) => Effect.die(error),
ACPCatalogNotReadyError: (error) =>
Effect.fail(new ACPError.ServiceFailureError({ safeMessage: error.message, errorName: "CatalogNotReady" })),
}),
Effect.mapError((error) => (error instanceof RequestError ? error : ACPError.toRequestError(error))),
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
@@ -113,7 +115,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
const acp = ACPConnection.make(app, stream)
const connection = acp.connection
const sessions = yield* ACPSessions.make({ client, connection, catalog })
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false, compaction: false })
const capabilities = yield* Ref.make(ACPCapabilities.parse(undefined))
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
return acp.agent
+17
View File
@@ -0,0 +1,17 @@
import type { ClientCapabilities } from "@agentclientprotocol/sdk"
export const ChildSessionUpdates = "opencode/child-session-updates"
export type Capabilities = ReturnType<typeof parse>
export function parse(client: ClientCapabilities | undefined) {
const elicitation = client?.elicitation
const compaction = client?.session?.compaction
return {
childSessionUpdates: client?._meta?.[ChildSessionUpdates] === true,
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
compaction: compaction !== undefined && compaction !== null,
}
}
export * as ACPCapabilities from "./capabilities"
+18 -18
View File
@@ -3,7 +3,7 @@ import type { Agent } from "@opencode/schema/agent"
import type { Command } from "@opencode/schema/command"
import type { Model } from "@opencode/schema/model"
import { FSUtil } from "@opencode/util/fs-util"
import { Context, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
import { Cause, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
import type { ConfigOptionProvider } from "./config-option"
export const builtinCommands = new Map([
@@ -19,7 +19,7 @@ export type Catalog = {
readonly commands: ReadonlyArray<Command.Info>
}
export class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
reason: Schema.Literals(["models", "agents"]),
}) {
override get message() {
@@ -27,7 +27,7 @@ export class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatal
}
}
export class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
cause: Schema.Defect(),
}) {}
@@ -39,14 +39,12 @@ export interface Interface {
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Catalog") {}
type Entry = {
readonly cwd: string
readonly catalog: SubscriptionRef.SubscriptionRef<Catalog>
readonly lock: Semaphore.Semaphore
requested: number
loaded: number
requestedGeneration: number
loadedGeneration: number
}
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
@@ -59,17 +57,17 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
// Requests queued behind a running load share the next one.
const reload = (entry: Entry) =>
Effect.suspend(() => {
const target = ++entry.requested
const target = ++entry.requestedGeneration
return entry.lock.withPermit(
Effect.suspend(() => {
if (entry.loaded >= target) return Effect.void
const generation = entry.requested
if (entry.loadedGeneration >= target) return Effect.void
const generation = entry.requestedGeneration
return load(client, entry.cwd).pipe(
Effect.flatMap((next) => SubscriptionRef.set(entry.catalog, next)),
Effect.ignore,
Effect.andThen(
Effect.sync(() => {
entry.loaded = generation
entry.loadedGeneration = generation
}),
),
)
@@ -90,7 +88,9 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
{ discard: true },
)
}),
Effect.ignore,
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP catalog event stream failed", cause),
),
Effect.ensuring(Deferred.succeed(connected, undefined)),
Effect.forkScoped,
)
@@ -101,8 +101,8 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
cwd,
catalog: yield* SubscriptionRef.make<Catalog>(yield* load(client, cwd)),
lock: Semaphore.makeUnsafe(1),
requested: 0,
loaded: 0,
requestedGeneration: 0,
loadedGeneration: 0,
} satisfies Entry
})
@@ -123,8 +123,8 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
)
})
return Service.of({
get: Effect.fn("cli.acp.catalog.get")(function* (cwd) {
return {
get: Effect.fnUntraced(function* (cwd) {
const loaded = yield* entry(cwd)
return yield* SubscriptionRef.get(loaded.catalog)
}),
@@ -132,7 +132,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
yield* reload(yield* entry(cwd))
}),
changes: (cwd) => Stream.unwrap(entry(cwd).pipe(Effect.map((loaded) => SubscriptionRef.changes(loaded.catalog)))),
})
} satisfies Interface
})
const load = (client: OpenCodeClient, cwd: string) =>
@@ -181,7 +181,7 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
} satisfies Catalog
})
function providers(models: ReadonlyArray<Model.Info>): ConfigOptionProvider[] {
function providers(models: ReadonlyArray<Model.Info>) {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
+68
View File
@@ -0,0 +1,68 @@
import type { SessionUpdate } from "@agentclientprotocol/sdk"
export const UpdateMethod = "opencode/session/child_update"
export type Session = {
readonly id: string
readonly parentID: string
readonly depth: number
readonly title?: string
}
export type Event =
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
}
export type Update = {
readonly rootSessionId: string
readonly childSessionId: string
readonly parentSessionId: string
readonly depth: number
readonly title?: string
} & Event
export function update(rootSessionID: string, child: Session, event: Event): Update {
return {
rootSessionId: rootSessionID,
childSessionId: child.id,
parentSessionId: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
...event,
}
}
export function project(sessionUpdate: SessionUpdate, child: Session) {
const projected = { ...sessionUpdate }
projected._meta = { ...projected._meta, ...meta(child) }
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = toolCallID(child, projected.toolCallId)
if (projected.title) projected.title = prefixTitle(child, projected.title)
}
return projected
}
export function meta(child: Session) {
return {
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
}
export function toolCallID(child: Session | undefined, id: string) {
return child ? `${child.id}:${id}` : id
}
export function prefixTitle(child: Session | undefined, value: string) {
return child?.title ? `${child.title}: ${value}` : value
}
export * as ACPChild from "./child"
+6 -9
View File
@@ -1,5 +1,5 @@
import { ClientError } from "@opencode/client/effect"
import { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
import { HttpClientError } from "effect/unstable/http"
@@ -9,23 +9,20 @@ export function classify(error: unknown): Effect.Effect<never, ACPError.Error> {
if (
error instanceof ClientError &&
HttpClientError.isHttpClientError(error.cause) &&
isConnectionFailure(error.cause)
// The client reports a failed body read as a `DecodeError` with a cause; its other `DecodeError`s have none.
(error.cause.reason._tag === "TransportError" ||
(error.cause.reason._tag === "DecodeError" && error.cause.reason.cause !== undefined))
)
return Effect.fail(new ACPError.ServerUnavailableError())
if (error instanceof SessionNotFoundError)
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: error.sessionID }))
if (error instanceof InvalidRequestError)
return Effect.fail(new ACPError.InvalidRequestError({ message: error.message, field: error.field }))
if (error instanceof InvalidCursorError)
return Effect.fail(new ACPError.InvalidRequestError({ message: error.message, field: "cursor" }))
return Effect.die(error)
}
// The client reports a failed body read as a `DecodeError` with a cause; its other `DecodeError`s have none.
function isConnectionFailure(error: HttpClientError.HttpClientError) {
return (
error.reason._tag === "TransportError" || (error.reason._tag === "DecodeError" && error.reason.cause !== undefined)
)
}
export function decodeSessionID(value: string) {
return Schema.decodeUnknownEffect(Session.ID)(value).pipe(
Effect.mapError(() => new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
+21 -15
View File
@@ -5,7 +5,7 @@ import { SessionMessage } from "@opencode/schema/session-message"
const MarkerMeta = "opencode/compaction"
export type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
type Compaction =
| Started
@@ -43,11 +43,11 @@ export function usesStandardUpdates(
return ctx.compaction && (!child || ctx.childUpdates)
}
export function apply(event: LifecycleEvent, tracked: Tracked, standard: boolean) {
export function apply(event: LifecycleEvent, tracked: Tracked, standardUpdates: boolean) {
const sessionID = event.data.sessionID
if (event.type === "session.compaction.started") {
const started = open(event)
return { tracked: new Map(tracked).set(sessionID, started), updates: [update(started, standard)] }
return { tracked: new Map(tracked).set(sessionID, started), updates: [update(started, standardUpdates)] }
}
const current = tracked.get(sessionID)
const remaining = new Map(tracked)
@@ -60,35 +60,41 @@ export function apply(event: LifecycleEvent, tracked: Tracked, standard: boolean
reason: event.data.reason,
summary: event.data.text,
}
return { tracked: remaining, updates: [update(completed, standard)] }
return { tracked: remaining, updates: [update(completed, standardUpdates)] }
}
// Auto compaction can fail without a started event; open it first for standard clients.
const started = current ?? open(event)
const failed: Compaction = { ...started, status: "failed", reason: event.data.reason, error: event.data.error }
return {
tracked: remaining,
updates: [...(current || !standard ? [] : [update(started, standard)]), update(failed, standard)],
updates: [
...(current || !standardUpdates ? [] : [update(started, standardUpdates)]),
update(failed, standardUpdates),
],
}
}
export function chunk(started: Started | undefined, text: string, standard: boolean): SessionUpdate | undefined {
if (!started || !standard) return undefined
export function chunk(started: Started | undefined, text: string, standardUpdates: boolean): SessionUpdate | undefined {
if (!started || !standardUpdates) return undefined
return { sessionUpdate: "compaction_summary_chunk", compactionId: started.messageId, content: { type: "text", text } }
}
export function abandon(started: Started, standard: boolean) {
return update({ ...started, status: "failed", error: { type: "aborted", message: "Compaction cancelled" } }, standard)
export function abandon(started: Started, standardUpdates: boolean) {
return update(
{ ...started, status: "failed", error: { type: "aborted", message: "Compaction cancelled" } },
standardUpdates,
)
}
// Nothing on this connection would settle a replayed running compaction.
export function replay(message: Extract<SessionMessage.Info, { type: "compaction" }>, standard: boolean) {
export function replay(message: Extract<SessionMessage.Info, { type: "compaction" }>, standardUpdates: boolean) {
if (message.status === "running") return undefined
const base = { messageId: message.id, reason: message.reason }
return update(
message.status === "failed"
? { ...base, status: "failed", error: message.error }
: { ...base, status: "completed", summary: message.summary },
standard,
standardUpdates,
)
}
@@ -101,8 +107,8 @@ function open(event: OpeningEvent): Started {
}
}
function update(compaction: Compaction, standard: boolean): SessionUpdate {
if (!standard) {
function update(compaction: Compaction, standardUpdates: boolean): SessionUpdate {
if (!standardUpdates) {
const marker = {
status: compaction.status,
messageId: compaction.messageId,
@@ -125,12 +131,12 @@ function update(compaction: Compaction, standard: boolean): SessionUpdate {
compactionId,
status: "failed",
summary: null,
error: errorMessage(compaction.error),
error: displayError(compaction.error),
}
}
// Core reports a defect as `compaction.failed` with a multi-line pretty-printed cause.
function errorMessage(error: SessionError.Error) {
function displayError(error: SessionError.Error) {
if (!error.message || (error.type === "compaction.failed" && error.message.includes("\n"))) return "Compaction failed"
return error.message
}
+2 -4
View File
@@ -5,7 +5,7 @@ import { builtinCommands, type Catalog } from "./catalog"
export const DEFAULT_VARIANT_VALUE = "default"
export type ConfigOptionModel = {
type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
@@ -17,7 +17,7 @@ export type ConfigOptionProvider = {
models: ReadonlyArray<ConfigOptionModel>
}
export type ConfigOptionMode = {
type ConfigOptionMode = {
id: string
name: string
description?: string
@@ -163,5 +163,3 @@ function selectVariant(variant: string | undefined, variants: readonly string[])
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
}
export * as ACPConfigOption from "./config-option"
+2 -4
View File
@@ -29,8 +29,6 @@ export interface Interface {
readonly responded: (requestId: JsonRpcId) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Connection") {}
export function make(app: AgentApp, stream: Stream) {
const responses = new Map<JsonRpcId, Deferred.Deferred<void>>()
const writer = stream.writable.getWriter()
@@ -51,7 +49,7 @@ export function make(app: AgentApp, stream: Stream) {
})
return {
agent,
connection: Service.of({
connection: {
sessionUpdate: (params) => promise(() => agent.client.notify(methods.client.session.update, params)),
requestPermission: (params) =>
promise((signal) =>
@@ -67,7 +65,7 @@ export function make(app: AgentApp, stream: Stream) {
responses.set(requestId, responded)
return Deferred.await(responded)
},
}),
} satisfies Interface,
}
}
+16 -20
View File
@@ -1,18 +1,19 @@
import type { ContentBlock, ContentChunk, ResourceLink } from "@agentclientprotocol/sdk"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { Result } from "effect"
export type PromptPart =
| { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean }
| { readonly type: "file"; readonly url: string; readonly filename?: string; readonly mime: string }
export type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
return content.flatMap(contentBlockToParts)
}
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text": {
const audience = block.annotations?.audience
@@ -35,22 +36,19 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] {
return [resourceLinkToPart(block)]
case "resource":
if ("text" in block.resource) {
try {
const parsed = new URL(block.resource.uri)
if (parsed.protocol === "file:") {
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
const decoded = (() => {
try {
return fileURLToPath(parsed)
} catch {
return decodeURIComponent(parsed.pathname)
}
})()
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
}
} catch {}
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
const parsed = URL.canParse(block.resource.uri) ? new URL(block.resource.uri) : undefined
const decoded =
parsed?.protocol === "file:"
? Result.try(() => fileURLToPath(parsed)).pipe(
Result.orElse(() => Result.try(() => decodeURIComponent(parsed.pathname))),
Result.getOrUndefined,
)
: undefined
if (!parsed || decoded === undefined)
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
}
if (!block.resource.mimeType) return []
return [
@@ -171,5 +169,3 @@ function filenameFromUri(uri: string | undefined): string | undefined {
}
return path.basename(uri) || undefined
}
export * as ACPContent from "./content"
+13 -11
View File
@@ -6,10 +6,11 @@ import type {
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/effect"
import { Form } from "@opencode/schema/form"
import { Session } from "@opencode/schema/session"
import { Cause, Effect, Option, Schema } from "effect"
import type { Capabilities } from "./capabilities"
import { ACPChild } from "./child"
import { ACPClient } from "./client"
import type { ACPConnection } from "./connection"
import type { ACPService } from "./service"
export type AskedForm = Omit<Form.Info, "id"> & { readonly id: string }
type InputField = Exclude<Form.Field, Form.ExternalField>
@@ -27,7 +28,7 @@ type Input = {
readonly form: Form.Info
readonly requestedSchema: ElicitationSchema
readonly clientSessionID: string
readonly child?: { readonly id: string; readonly title?: string }
readonly child?: ACPChild.Session
readonly toolCallSent: boolean
readonly settled: Effect.Effect<void>
}
@@ -58,7 +59,7 @@ function cancel(client: OpenCodeClient, form: Form.Info, message?: string) {
return client.session.form.cancel({ sessionID: form.sessionID, formID: form.id, message }).pipe(
Effect.catchTag(["FormAlreadySettledError", "FormNotFoundError"], () => Effect.void),
Effect.catch(() =>
Schema.decodeUnknownEffect(Session.ID)(form.sessionID).pipe(
ACPClient.decodeSessionID(form.sessionID).pipe(
Effect.flatMap((sessionID) => client.session.interrupt({ sessionID })),
Effect.ignore,
),
@@ -66,7 +67,7 @@ function cancel(client: OpenCodeClient, form: Form.Info, message?: string) {
)
}
export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabilities): ElicitationSchema | undefined {
export function requestedSchema(form: AskedForm, capabilities: Capabilities): ElicitationSchema | undefined {
if (!capabilities.formElicitation) return undefined
if (Option.isNone(Schema.decodeUnknownOption(ElicitedKind)(form.metadata))) return undefined
if (form.fields.some(credentialLike)) return undefined
@@ -90,7 +91,7 @@ export function cancelUnshown(client: OpenCodeClient, form: Form.Info) {
)
}
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
if (response.action !== "accept") return undefined
const content = Schema.decodeUnknownOption(Form.Answer)(response.content ?? {})
if (Option.isNone(content)) return undefined
@@ -108,8 +109,8 @@ const ask = Effect.fnUntraced(function* (input: Input) {
const response = yield* input.connection.createElicitation({
mode: "form",
sessionId: input.clientSessionID,
...(toolCallID ? { toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID } : {}),
message: input.child?.title ? `${input.child.title}: ${input.form.title}` : input.form.title,
...(toolCallID ? { toolCallId: ACPChild.toolCallID(input.child, toolCallID) } : {}),
message: ACPChild.prefixTitle(input.child, input.form.title),
requestedSchema: input.requestedSchema,
})
return answer(input.form, response) ?? "cancel"
@@ -152,7 +153,8 @@ function properties(field: InputField): Array<[string, ElicitationPropertySchema
const base = { title: field.title, description: field.description }
switch (field.type) {
case "string": {
if (!hasOptions(field)) return [[field.key, { type: "string", ...base, ...text(field), default: field.default }]]
if (!hasOptions(field))
return [[field.key, { type: "string", ...base, ...stringConstraints(field), default: field.default }]]
const select: ElicitationPropertySchema = {
type: "string",
...base,
@@ -192,13 +194,13 @@ function other(field: SelectField, description: string): [string, ElicitationPro
type: "string",
title: `${field.title ?? field.key} (other)`,
description,
...(field.type === "string" ? text(field) : {}),
...(field.type === "string" ? stringConstraints(field) : {}),
},
]
}
// Core rejects an empty string for a required field.
function text(field: Form.StringField) {
function stringConstraints(field: Form.StringField) {
return {
format: field.format,
minLength: field.required ? Math.max(field.minLength ?? 0, 1) : field.minLength,
+17 -17
View File
@@ -1,5 +1,6 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
import type { ACPCatalog } from "./catalog"
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
@@ -55,22 +56,21 @@ export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailable
{},
) {}
const Errors = Schema.Union([
SessionNotFoundError,
SessionDirectoryMismatchError,
InvalidConfigOptionError,
InvalidModelError,
InvalidEffortError,
InvalidModeError,
InvalidAdditionalDirectoryError,
AuthRequiredError,
UnknownAuthMethodError,
InvalidRequestError,
ServiceFailureError,
ServerUnavailableError,
])
export type Error =
| SessionNotFoundError
| SessionDirectoryMismatchError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| InvalidAdditionalDirectoryError
| AuthRequiredError
| UnknownAuthMethodError
| InvalidRequestError
| ServiceFailureError
| ServerUnavailableError
export type Error = typeof Errors.Type
export type Failure = Error | RequestError | ACPCatalog.Error
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
@@ -118,9 +118,9 @@ export function toRequestError(error: Error): RequestError {
return exhaustive
}
export function fromUnknown(error: unknown, service?: string) {
export function fromUnknown(error: unknown) {
const errorName = error instanceof Error ? error.name : undefined
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
return new ServiceFailureError({ safeMessage: "Internal service failure", errorName })
}
export * as ACPError from "./error"
+23 -28
View File
@@ -1,4 +1,4 @@
import type { PermissionOption, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { PermissionOption } from "@agentclientprotocol/sdk"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
import { FileDiff } from "@opencode/schema/file-diff"
import type { Permission } from "@opencode/schema/permission"
@@ -6,9 +6,9 @@ import type { Session } from "@opencode/schema/session"
import { Patch } from "@opencode/util/patch"
import { applyPatch } from "diff"
import { Cause, Effect, Option, Schema } from "effect"
import { ACPChild } from "./child"
import { ACPClient } from "./client"
import type { ACPConnection } from "./connection"
import { ACPTranslate } from "./translate"
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
@@ -23,7 +23,8 @@ type Input = {
readonly clientSessionID: string
readonly cwd: string
readonly tool?: Tool
readonly child?: ACPTranslate.ChildSession
readonly child?: ACPChild.Session
readonly settled: Effect.Effect<void>
}
const options: PermissionOption[] = [
@@ -37,7 +38,13 @@ const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
yield* Effect.uninterruptibleMask((restore) =>
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
restore(cancelled.pipe(Effect.as("reject" as const), Effect.raceFirst(ask(input)))).pipe(
restore(
cancelled.pipe(
Effect.as("reject" as const),
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
Effect.raceFirst(ask(input)),
),
).pipe(
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
Effect.catchCause(() => Effect.succeed("reject" as const)),
Effect.flatMap((decision) => respond(input, decision)),
@@ -51,14 +58,11 @@ const ask = Effect.fnUntraced(function* (input: Input) {
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
Effect.orElseSucceed((): Preview[] => []),
)
const toolCallID = input.tool?.id ?? input.event.data.id
const title = permissionTitle(toolName, toolInput, previews)
const toolCall = pendingToolCall({
toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID,
toolCallId: ACPChild.toolCallID(input.child, input.tool?.id ?? input.event.data.id),
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.child?.title, permissionTitle(toolName, toolInput, previews)),
},
state: { input: toolInput, title: title ? ACPChild.prefixTitle(input.child, title) : input.child?.title },
cwd: input.cwd,
})
const result = yield* input.connection.requestPermission({
@@ -68,7 +72,7 @@ const ask = Effect.fnUntraced(function* (input: Input) {
rawInput: input.tool ? toolCall.rawInput : undefined,
locations: permissionLocations(toolName, toolInput, input.event.data, input.cwd),
...(previews.length > 0 ? { content: previews } : {}),
...(input.child ? { _meta: ACPTranslate.childSessionMeta(input.child) } : {}),
...(input.child ? { _meta: ACPChild.meta(input.child) } : {}),
},
options,
})
@@ -76,16 +80,12 @@ const ask = Effect.fnUntraced(function* (input: Input) {
return selected === "once" || selected === "always" ? selected : "reject"
})
function respond(input: Input, decision: Permission.Reply) {
return input.client.permission
.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision })
.pipe(Effect.catch(ACPClient.classify))
}
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
if (!prefix) return title
if (!title) return prefix
return `${prefix}: ${title}`
function respond(input: Input, decision: Permission.Reply | "settled") {
if (decision === "settled") return Effect.void
return input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }).pipe(
Effect.catchTag("PermissionNotFoundError", () => Effect.void),
Effect.catch(ACPClient.classify),
)
}
// Core trims the patch tool's diffs for display, which breaks `applyPatch`, so its previews come from its own hunks.
@@ -104,7 +104,7 @@ const permissionPreviews = Effect.fnUntraced(function* (
Effect.gen(function* () {
const path = absolutePath(file.file, cwd)
const oldText = file.status === "added" ? null : yield* Effect.tryPromise(() => Bun.file(path).text())
const newText = applyPatch(oldText ?? "", file.patch)
const newText = yield* Effect.try(() => applyPatch(oldText ?? "", file.patch))
return newText === false ? [] : [diff(path, oldText, newText)]
}),
{ concurrency: "unbounded" },
@@ -158,12 +158,7 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
}
}
function permissionLocations(
toolName: string,
input: ToolInput,
ask: PermissionEvent["data"],
cwd: string,
): ToolCallLocation[] {
function permissionLocations(toolName: string, input: ToolInput, ask: PermissionEvent["data"], cwd: string) {
const locations = toLocations(toolName, input, cwd)
if (locations.length > 0 || !PathActions.has(ask.action)) return locations
const paths = ask.resources.flatMap((resource) => {
+127
View File
@@ -0,0 +1,127 @@
import type { SessionUpdate } from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/effect"
import type { SessionMessage } from "@opencode/schema/session-message"
import { Effect, Option, Stream } from "effect"
import type { Capabilities } from "./capabilities"
import { ACPClient } from "./client"
import { ACPCompaction } from "./compaction"
import type { ACPConnection } from "./connection"
import { partsToContentChunks } from "./content"
import type { Attached } from "./sessions"
import { ACPTranslate } from "./translate"
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate } from "./tool"
export function history(
client: OpenCodeClient,
connection: ACPConnection.Interface,
attached: Attached,
capabilities: Capabilities,
) {
return Stream.paginate(undefined, (cursor: string | undefined) =>
(cursor
? client.message.list({ sessionID: attached.id, limit: 200, cursor })
: client.message.list({ sessionID: attached.id, limit: 200, order: "asc" })
).pipe(
Effect.catch(ACPClient.classify),
Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const),
),
).pipe(
Stream.runForEach((message) =>
Effect.forEach(
updates(message, attached.cwd, capabilities),
(update) => connection.sessionUpdate({ sessionId: attached.id, update }),
{ discard: true },
),
),
)
}
export function updates(message: SessionMessage.Info, cwd: string, capabilities: Capabilities): SessionUpdate[] {
if (message.type === "user")
return [
{ sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } },
...partsToContentChunks(
(message.files ?? []).map((file) => ({
type: "file",
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
filename: file.name,
mime: file.mime,
})),
).map((chunk) => ({ sessionUpdate: "user_message_chunk" as const, messageId: message.id, ...chunk })),
]
if (message.type === "compaction") {
const update = ACPCompaction.replay(message, capabilities.compaction)
return update ? [update] : []
}
if (message.type !== "assistant") return []
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
const reasoning = message.content.filter((part) => part.type === "reasoning")
return message.content.flatMap((part): SessionUpdate[] => {
if (part.type === "text")
return [
{ sessionUpdate: "agent_message_chunk", messageId: message.id, content: { type: "text", text: part.text } },
]
if (part.type === "reasoning")
return [
{
sessionUpdate: "agent_thought_chunk",
messageId: ACPTranslate.reasoningMessageID(message.id, reasoning.indexOf(part)),
content: { type: "text", text: part.text },
},
]
const call: SessionUpdate = {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.status === "streaming" ? {} : part.state.input },
cwd,
}),
}
switch (part.state.status) {
case "completed":
return [
call,
{
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
cwd,
}),
},
]
case "running":
return [
call,
{
sessionUpdate: "tool_call_update",
...runningToolUpdate({ toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, cwd }),
},
]
case "error":
return [
call,
{
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
error: part.state.error.message,
cwd,
}),
},
]
case "streaming":
return [call]
}
})
}
export * as ACPReplay from "./replay"
+48 -61
View File
@@ -3,7 +3,7 @@ import { SessionsCursor } from "@opencode/protocol/groups/session"
import { Model } from "@opencode/schema/model"
import { AbsolutePath } from "@opencode/schema/schema"
import { FSUtil } from "@opencode/util/fs-util"
import { DateTime, Effect, Option, Ref, Schema, Stream } from "effect"
import { DateTime, Effect, Ref, Schema } from "effect"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import type {
AuthenticateRequest,
@@ -22,11 +22,11 @@ import type {
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
McpServer,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
RequestError,
ResumeSessionRequest,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
@@ -35,41 +35,34 @@ import type {
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { ACPCapabilities, type Capabilities } from "./capabilities"
import type { ACPCatalog, Catalog } from "./catalog"
import { ACPClient } from "./client"
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
import type { ACPConnection } from "./connection"
import { ACPDirectories } from "./directories"
import { ACPError } from "./error"
import type { ACPSessions, Attached } from "./sessions"
import { ACPTranslate } from "./translate"
import { ACPReplay } from "./replay"
import type { ACPSessions, Attached, SupportedMcpServer } from "./sessions"
import type { ACPTurn } from "./turn"
export const AuthMethodID = "opencode-login"
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
export type Capabilities = {
readonly childSessionUpdates: boolean
readonly formElicitation: boolean
readonly compaction: boolean
}
const AuthMethodID = "opencode-login"
export interface Interface {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse>
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Failure>
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Failure>
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Failure>
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Failure>
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, Failure>
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Failure>
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Failure>
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Failure>
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, ACPError.Failure>
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, ACPError.Failure>
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, ACPError.Failure>
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, ACPError.Failure>
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, ACPError.Failure>
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, ACPError.Failure>
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, ACPError.Failure>
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, ACPError.Failure>
readonly setSessionConfigOption: (
input: SetSessionConfigOptionRequest,
) => Effect.Effect<SetSessionConfigOptionResponse, Failure>
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Failure>
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
) => Effect.Effect<SetSessionConfigOptionResponse, ACPError.Failure>
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, ACPError.Failure>
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, ACPError.Failure>
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
}
@@ -85,7 +78,7 @@ export function make(input: {
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
})
const withReload = <A>(attached: Attached, select: Effect.Effect<A, Failure>) => {
const withReload = <A>(attached: Attached, select: Effect.Effect<A, ACPError.Failure>) => {
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(select))
return select.pipe(
Effect.catchTags({ ACPInvalidModelError: retry, ACPInvalidModeError: retry, ACPInvalidEffortError: retry }),
@@ -131,36 +124,9 @@ export function make(input: {
return session
})
const replay = Effect.fnUntraced(function* (attached: Attached) {
const capabilities = yield* Ref.get(input.capabilities)
yield* Stream.paginate(undefined, (cursor: string | undefined) =>
(cursor
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" })
).pipe(
Effect.catch(ACPClient.classify),
Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const),
),
).pipe(
Stream.runForEach((message) =>
Effect.forEach(
ACPTranslate.replayMessage(message, attached.cwd, capabilities),
(update) => input.connection.sessionUpdate({ sessionId: attached.id, update }),
{ discard: true },
),
),
)
})
return {
initialize: Effect.fnUntraced(function* (params) {
const elicitation = params.clientCapabilities?.elicitation
const compaction = params.clientCapabilities?.session?.compaction
yield* Ref.set(input.capabilities, {
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
compaction: compaction !== undefined && compaction !== null,
})
yield* Ref.set(input.capabilities, ACPCapabilities.parse(params.clientCapabilities))
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -178,7 +144,7 @@ export function make(input: {
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { [ACPTranslate.ChildSessionUpdatesCapability]: true },
_meta: { [ACPCapabilities.ChildSessionUpdates]: true },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
@@ -191,23 +157,30 @@ export function make(input: {
}),
newSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
const mcpServers = yield* supportedMcpServers(params.mcpServers)
// Load first so a catalog failure leaves no session.
yield* input.catalog.get(params.cwd)
const created = yield* input.client.session
.create({ location: { directory: AbsolutePath.make(params.cwd) }, ...ACPDirectories.grant(directories) })
.pipe(Effect.catch(ACPClient.classify))
const attachment = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
const attachment = yield* input.sessions.attach(created, params.cwd, mcpServers)
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
}),
loadSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
const mcpServers = yield* supportedMcpServers(params.mcpServers)
const session = yield* getSession(params.sessionId, params.cwd)
yield* ACPDirectories.activate(input.client, session, directories)
const attached = (yield* input.sessions.attach(session, session.location.directory, params.mcpServers)).attached
return yield* replay(attached).pipe(
Effect.andThen(currentOptions(attached)),
const attachment = yield* input.sessions.attach(session, session.location.directory, mcpServers)
return yield* ACPReplay.history(
input.client,
input.connection,
attachment.attached,
yield* Ref.get(input.capabilities),
).pipe(
Effect.andThen(currentOptions(attachment.attached)),
Effect.map((configOptions) => ({ configOptions })),
Effect.onError(() => input.sessions.detach(attached.id)),
Effect.onError(() => input.sessions.release(attachment.attached)),
)
}),
listSessions: Effect.fnUntraced(function* (params) {
@@ -247,9 +220,10 @@ export function make(input: {
}),
resumeSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
const mcpServers = yield* supportedMcpServers(params.mcpServers)
const session = yield* getSession(params.sessionId, params.cwd)
yield* ACPDirectories.activate(input.client, session, directories)
const attachment = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
const attachment = yield* input.sessions.attach(session, session.location.directory, mcpServers)
return { configOptions: attachment.configOptions }
}),
closeSession: Effect.fnUntraced(function* (params) {
@@ -259,11 +233,12 @@ export function make(input: {
}),
forkSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
const mcpServers = yield* supportedMcpServers(params.mcpServers)
const sessionID = yield* ACPClient.decodeSessionID(params.sessionId)
const forked = yield* input.client.session.fork({ sessionID }).pipe(Effect.catch(ACPClient.classify))
// Forks inherit the source's grants; replace them with this request's.
yield* ACPDirectories.activate(input.client, forked, directories)
const attachment = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
const attachment = yield* input.sessions.attach(forked, forked.location.directory, mcpServers)
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
}),
setSessionConfigOption: Effect.fnUntraced(function* (params) {
@@ -283,6 +258,18 @@ export function make(input: {
}
}
const supportedMcpServers = Effect.fnUntraced(function* (servers: readonly McpServer[] = []) {
const supported = servers.filter(
(server): server is SupportedMcpServer => !("type" in server) || server.type === "http",
)
if (supported.length < servers.length)
return yield* new ACPError.InvalidRequestError({
message: "Only stdio and HTTP MCP servers are supported",
field: "mcpServers",
})
return supported
})
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: Model.Ref) {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
+26 -33
View File
@@ -1,15 +1,17 @@
import { isDeepStrictEqual } from "node:util"
import type { McpServer, RequestError, SessionConfigOption } from "@agentclientprotocol/sdk"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
import { Mcp } from "@opencode/schema/mcp"
import type { Session } from "@opencode/schema/session"
import { Context, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
import type { ACPCatalog, Catalog } from "./catalog"
import { ACPClient } from "./client"
import { availableCommands, configOptions, type Selection } from "./config-option"
import { ACPConnection } from "./connection"
import { ACPError } from "./error"
export type SupportedMcpServer = Exclude<McpServer, { readonly type: "acp" | "sse" }>
export type Attached = {
readonly id: Session.ID
readonly cwd: string
@@ -20,18 +22,14 @@ export interface Interface {
readonly attach: (
session: Session.Info,
cwd: string,
mcpServers: readonly McpServer[],
) => Effect.Effect<
{ readonly attached: Attached; readonly configOptions: SessionConfigOption[] },
ACPError.Error | RequestError | ACPCatalog.Error
>
mcpServers: readonly SupportedMcpServer[],
) => Effect.Effect<{ readonly attached: Attached; readonly configOptions: SessionConfigOption[] }, ACPError.Failure>
readonly detach: (sessionID: string) => Effect.Effect<void>
readonly release: (attached: Attached) => Effect.Effect<void>
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Sessions") {}
type Entry = {
readonly attached: Attached
readonly scope: Scope.Closeable
@@ -48,7 +46,7 @@ export const make = Effect.fnUntraced(function* (input: {
const scope = yield* Effect.scope
const sessions = new Map<string, Entry>()
// Outlives entries so re-attaching does not re-add servers.
const registeredMcp = new Map<string, Set<string>>()
const registeredMcpBySession = new Map<string, Map<string, Mcp.ServerConfig>>()
const connected = yield* Deferred.make<void>()
// Subscribe before any attach so a switch right after `sessions.set` reaches the session.
@@ -66,7 +64,9 @@ export const make = Effect.fnUntraced(function* (input: {
event.type === "session.model.selected" ? { model: event.data.model } : { modeID: event.data.agent },
)
}),
Effect.ignore,
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP selection event stream failed", cause),
),
Effect.ensuring(Deferred.succeed(connected, undefined)),
Effect.forkScoped,
)
@@ -89,21 +89,20 @@ export const make = Effect.fnUntraced(function* (input: {
if (!isDeepStrictEqual(next.commands, previous.commands)) yield* sendCommands(attached.id, next)
})
const registerMcp = (attached: Attached, servers: readonly McpServer[]) =>
const registerMcp = (attached: Attached, servers: readonly SupportedMcpServer[]) =>
Effect.suspend(() => {
const registered = registeredMcp.get(attached.id) ?? new Set<string>()
registeredMcp.set(attached.id, registered)
const registered = registeredMcpBySession.get(attached.id) ?? new Map<string, Mcp.ServerConfig>()
registeredMcpBySession.set(attached.id, registered)
return Effect.forEach(
servers,
(server) =>
Effect.suspend(() => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (registered.has(key)) return Effect.void
registered.add(key)
if (isDeepStrictEqual(registered.get(server.name), config)) return Effect.void
registered.set(server.name, config)
return input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }).pipe(
Effect.catch(ACPClient.classify),
Effect.onError(() => Effect.sync(() => registered.delete(key))),
Effect.onError(() => Effect.sync(() => registered.delete(server.name))),
Effect.uninterruptible,
)
}),
@@ -115,12 +114,12 @@ export const make = Effect.fnUntraced(function* (input: {
Effect.suspend(() => {
if (sessions.get(sessionID) === entry) {
sessions.delete(sessionID)
registeredMcp.delete(sessionID)
registeredMcpBySession.delete(sessionID)
}
return Scope.close(entry.scope, Exit.void)
})
return Service.of({
return {
attach: Effect.fn("cli.acp.sessions.attach")(function* (session, cwd, mcpServers) {
yield* Deferred.await(connected)
const current = yield* input.catalog.get(cwd)
@@ -166,7 +165,11 @@ export const make = Effect.fnUntraced(function* (input: {
const entry = sessions.get(sessionID)
if (entry) yield* remove(sessionID, entry)
}),
require: Effect.fn("cli.acp.sessions.require")(function* (sessionID) {
release: Effect.fn("cli.acp.sessions.release")(function* (attached) {
const entry = sessions.get(attached.id)
if (entry?.attached === attached) yield* remove(attached.id, entry)
}),
require: Effect.fnUntraced(function* (sessionID) {
const entry = sessions.get(sessionID)
if (!entry) return yield* new ACPError.SessionNotFoundError({ sessionId: sessionID })
return entry.attached
@@ -176,12 +179,11 @@ export const make = Effect.fnUntraced(function* (input: {
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
}),
})
} satisfies Interface
})
function mcpConfig(server: McpServer) {
function mcpConfig(server: SupportedMcpServer) {
if ("type" in server) {
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
return new Mcp.RemoteConfig({
type: "remote",
url: server.url,
@@ -196,13 +198,4 @@ function mcpConfig(server: McpServer) {
})
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
if (!value || typeof value !== "object") return JSON.stringify(value)
return `{${Object.entries(value)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`
}
export * as ACPSessions from "./sessions"
+9 -16
View File
@@ -1,16 +1,13 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
import type { Tool } from "@opencode/schema/tool"
import { readDisplayText } from "@opencode/tui/mini/tool"
import { Patch } from "@opencode/util/patch"
import { Result } from "effect"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | null }
>
export function toToolKind(toolName: string): ToolKind {
function toToolKind(toolName: string): ToolKind {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
@@ -85,18 +82,16 @@ export function pendingToolCall(input: {
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly content?: ToolContent
readonly state: { readonly input: ToolInput }
readonly cwd: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, input.state.title),
title: toolTitle(input.toolName, input.state.input),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
...(input.content?.length ? { content: toolContent(input.content) } : {}),
}
}
@@ -104,7 +99,7 @@ export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly content: ReadonlyArray<Tool.Content>
readonly metadata?: Readonly<Record<string, unknown>>
readonly cwd: string
}): ToolCallUpdate {
@@ -138,7 +133,7 @@ export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content?: ToolContent
readonly content?: ReadonlyArray<Tool.Content>
readonly metadata?: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd: string
@@ -147,7 +142,7 @@ export function errorToolUpdate(input: {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.input, undefined),
title: toolTitle(input.toolName, input.input),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
@@ -158,7 +153,7 @@ export function errorToolUpdate(input: {
}
}
function toolContent(content: ToolContent): ToolCallContent[] {
function toolContent(content: ReadonlyArray<Tool.Content>): ToolCallContent[] {
return content.flatMap((part): ToolCallContent[] => {
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
@@ -167,7 +162,7 @@ function toolContent(content: ToolContent): ToolCallContent[] {
})
}
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
function toolTitle(toolName: string, input: ToolInput, fallback?: string) {
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
return fallback || toolName
}
@@ -208,5 +203,3 @@ export function absolutePath(path: string, cwd: string) {
export function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPTool from "./tool"
+35 -171
View File
@@ -4,14 +4,11 @@ import type { Session } from "@opencode/schema/session"
import type { SessionError } from "@opencode/schema/session-error"
import type { SessionMessage } from "@opencode/schema/session-message"
import { TokenUsage } from "@opencode/schema/token-usage"
import { ACPChild } from "./child"
import { ACPCompaction } from "./compaction"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import type { ACPService } from "./service"
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
const RetryMeta = "opencode/retry"
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: SessionMessage.ID }
@@ -42,37 +39,14 @@ type RetryStatus = {
readonly error: SessionError.Error
}
export type ChildSession = {
readonly id: string
readonly parentID: string
readonly depth: number
readonly title?: string
}
type ChildSessionEvent =
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
}
export type ChildSessionUpdate = {
readonly rootSessionId: string
readonly childSessionId: string
readonly parentSessionId: string
readonly depth: number
readonly title?: string
} & ChildSessionEvent
export type TurnState = {
readonly started: boolean
readonly tools: ReadonlyMap<string, Tool>
readonly retries: ReadonlyMap<string, RetryStatus>
readonly compactions: ACPCompaction.Tracked
readonly children: ReadonlyMap<string, ChildSession>
readonly children: ReadonlyMap<string, ACPChild.Session>
readonly openChildren: ReadonlySet<string>
readonly forms: ReadonlySet<string>
readonly asks: ReadonlySet<string>
readonly finish?: SessionMessage.Assistant["finish"]
readonly usage?: { readonly turn: TokenUsage.Info; readonly last: TokenUsage.Info }
readonly stepError?: SessionError.Error
@@ -84,22 +58,22 @@ type FormEvent = Extract<OpenCodeEvent, { type: "form.created" }>
export type Output =
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
| { readonly _tag: "ChildUpdate"; readonly update: ChildSessionUpdate }
| { readonly _tag: "ChildUpdate"; readonly update: ACPChild.Update }
| {
readonly _tag: "PermissionAsk"
readonly event: PermissionEvent
readonly tool?: Tool
readonly child?: ChildSession
readonly child?: ACPChild.Session
}
| {
readonly _tag: "FormAsk"
readonly form: FormEvent["data"]["form"]
readonly child?: ChildSession
readonly child?: ACPChild.Session
readonly toolCallSent: boolean
}
| { readonly _tag: "FormSettled"; readonly formID: string }
| { readonly _tag: "AskSettled"; readonly id: string }
export type Step = {
type Step = {
readonly state: TurnState
readonly outputs: ReadonlyArray<Output>
readonly terminal?: Terminal
@@ -112,7 +86,7 @@ export const initial: TurnState = {
compactions: new Map(),
children: new Map(),
openChildren: new Set(),
forms: new Set(),
asks: new Set(),
}
export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step {
@@ -141,11 +115,14 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
const tool = event.data.source?.id
? state.tools.get(toolKey(event.data.sessionID, event.data.source.id))
: undefined
return { state, outputs: [{ _tag: "PermissionAsk", event, tool, child }] }
return {
state: { ...state, asks: new Set(state.asks).add(event.data.id) },
outputs: [{ _tag: "PermissionAsk", event, tool, child }],
}
}
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
return {
state: { ...state, forms: new Set(state.forms).add(event.data.form.id) },
state: { ...state, asks: new Set(state.asks).add(event.data.form.id) },
outputs: [
{
_tag: "FormAsk",
@@ -156,13 +133,20 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
],
}
}
if ((event.type === "form.replied" || event.type === "form.cancelled") && state.forms.has(event.data.id)) {
const forms = new Set(state.forms)
forms.delete(event.data.id)
return { state: { ...state, forms }, outputs: [{ _tag: "FormSettled", formID: event.data.id }] }
const settledID =
event.type === "permission.replied"
? event.data.requestID
: event.type === "form.replied" || event.type === "form.cancelled"
? event.data.id
: undefined
if (settledID && state.asks.has(settledID)) {
const asks = new Set(state.asks)
asks.delete(settledID)
return { state: { ...state, asks }, outputs: [{ _tag: "AskSettled", id: settledID }] }
}
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
if (matchesStart(event, ctx.start)) return { state: { ...state, started: true }, outputs: [] }
if (event.type === "session.inbox.delivered" && event.data.inboxID === ctx.start.id)
return { state: { ...state, started: true }, outputs: [] }
if (!state.started) return { state, outputs: [] }
switch (event.type) {
@@ -219,7 +203,7 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
state,
outputs: send({
sessionUpdate: "agent_thought_chunk",
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
messageId: reasoningMessageID(event.data.assistantMessageID, event.data.ordinal),
content: { type: "text", text: event.data.delta },
}),
}
@@ -396,126 +380,31 @@ export function abandon(state: TurnState, ctx: Context): Step {
}
}
export function* replayMessage(
message: SessionMessage.Info,
cwd: string,
capabilities: ACPService.Capabilities,
): Generator<SessionUpdate> {
if (message.type === "user") {
yield { sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } }
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
type: "file",
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
filename: file.name,
mime: file.mime,
}))
for (const chunk of partsToContentChunks(files))
yield { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk }
return
}
if (message.type === "compaction") {
const update = ACPCompaction.replay(message, capabilities.compaction)
if (update) yield update
return
}
if (message.type !== "assistant") return
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
let reasoningOrdinal = 0
for (const part of message.content) {
if (part.type === "text") {
yield { sessionUpdate: "agent_message_chunk", messageId: message.id, content: { type: "text", text: part.text } }
continue
}
if (part.type === "reasoning") {
yield {
sessionUpdate: "agent_thought_chunk",
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
content: { type: "text", text: part.text },
}
continue
}
yield {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.status === "streaming" ? {} : part.state.input },
cwd,
}),
}
switch (part.state.status) {
case "completed":
yield {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
cwd,
}),
}
break
case "running":
yield {
sessionUpdate: "tool_call_update",
...runningToolUpdate({ toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, cwd }),
}
break
case "error":
yield {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
error: part.state.error.message,
cwd,
}),
}
break
case "streaming":
break
}
}
export function reasoningMessageID(messageID: string, ordinal: number) {
return `${messageID}:reasoning:${ordinal}`
}
function newTool(sessionID: string, id: string, name = "tool"): Tool {
return { sessionID, id, name, input: {}, metadata: {} }
}
function route(ctx: Context, child: ChildSession | undefined, update: SessionUpdate): Output[] {
function route(ctx: Context, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
const projected = projectChildUpdate(update, child)
const projected = ACPChild.project(update, child)
if (ctx.childUpdates) return childStatus(ctx, child, { type: "update", update: projected })
return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update: projected }] : []
}
function childStatus(ctx: Context, child: ChildSession, value: ChildSessionEvent): Output[] {
function childStatus(ctx: Context, child: ACPChild.Session, event: ACPChild.Event): Output[] {
if (!ctx.childUpdates) return []
return [
{
_tag: "ChildUpdate",
update: {
rootSessionId: ctx.sessionID,
childSessionId: child.id,
parentSessionId: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
...value,
},
},
]
return [{ _tag: "ChildUpdate", update: ACPChild.update(ctx.sessionID, child, event) }]
}
function childEnded(
state: TurnState,
ctx: Context,
child: ChildSession,
status: ChildSessionEvent,
child: ACPChild.Session,
status: ACPChild.Event,
terminal: Terminal,
): Step {
const openChildren = new Set(state.openChildren)
@@ -561,31 +450,6 @@ function toolKey(sessionID: string, id: string) {
return `${sessionID}:${id}`
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = { ...projected._meta, ...childSessionMeta(child) }
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
export function childSessionMeta(child: ChildSession) {
return {
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
}
function matchesStart(event: OpenCodeEvent, start: TurnStart) {
return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
}
function resolveStopReason(input: {
readonly terminal: Terminal
readonly finish: SessionMessage.Assistant["finish"]
+65 -42
View File
@@ -1,7 +1,6 @@
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
import type { Command } from "@opencode/schema/command"
import type { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { TokenUsage } from "@opencode/schema/token-usage"
import {
@@ -21,7 +20,9 @@ import {
} from "effect"
import { access, constants } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import type { Capabilities } from "./capabilities"
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
import { ACPChild } from "./child"
import { ACPClient } from "./client"
import { currentModel } from "./config-option"
import type { ACPConnection } from "./connection"
@@ -29,21 +30,16 @@ import { linkReference, promptContentToParts, type PromptPart } from "./content"
import { ACPElicitation } from "./elicitation"
import { ACPError } from "./error"
import { ACPPermission } from "./permission"
import type { ACPService } from "./service"
import type { ACPSessions, Attached } from "./sessions"
import { ACPTranslate } from "./translate"
type Failure = ACPError.Error | RequestError | ACPCatalog.Error
export interface Interface {
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, ACPError.Failure>
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
/** Unlike `cancel`, interrupts an idle session too, since server work can outlive its turn. */
readonly close: (sessionID: string) => Effect.Effect<void, ACPError.Error | RequestError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Turn") {}
/** Core acknowledges an interrupt before its cleanup settles, and its shell tool waits 3s before SIGKILL. */
export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/cli/acp/Turn/CancelDrainTimeout", {
defaultValue: () => "5 seconds",
@@ -66,7 +62,7 @@ type Subscription = {
/** Asks run serially off the event stream. */
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
readonly cancelled: Deferred.Deferred<void>
readonly forms: Map<string, Deferred.Deferred<void>>
readonly settled: Map<string, Deferred.Deferred<void>>
}
export const make = Effect.fnUntraced(function* (input: {
@@ -74,11 +70,11 @@ export const make = Effect.fnUntraced(function* (input: {
readonly connection: ACPConnection.Interface
readonly sessions: ACPSessions.Interface
readonly catalog: ACPCatalog.Interface
readonly capabilities: Ref.Ref<ACPService.Capabilities>
readonly capabilities: Ref.Ref<Capabilities>
}) {
const scope = yield* Effect.scope
const drainTimeout = yield* CancelDrainTimeout
const turns = yield* FiberMap.make<string, PromptResponse, Failure>()
const turns = yield* FiberMap.make<string, PromptResponse, ACPError.Failure>()
const subscribe = Effect.fnUntraced(function* () {
// Parented to the service scope; the session scope may already be closed.
@@ -90,7 +86,7 @@ export const make = Effect.fnUntraced(function* (input: {
.pipe(Stream.toQueue({ capacity: "unbounded" }), Scope.provide(subscriptionScope)),
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
cancelled: yield* Deferred.make<void>(),
forms: new Map(),
settled: new Map(),
}
yield* Queue.take(subscription.asks).pipe(
Effect.flatten,
@@ -116,7 +112,12 @@ export const make = Effect.fnUntraced(function* (input: {
yield* Deferred.await(settled)
})
const reply = (subscription: Subscription, ctx: ACPTranslate.Context, ask: PermissionAsk) =>
const reply = (
subscription: Subscription,
ctx: ACPTranslate.Context,
ask: PermissionAsk,
settled: Deferred.Deferred<void>,
) =>
ACPPermission.reply(
{
client: input.client,
@@ -127,6 +128,7 @@ export const make = Effect.fnUntraced(function* (input: {
cwd: ctx.cwd,
tool: ask.tool,
child: ask.child,
settled: Deferred.await(settled),
},
Deferred.await(subscription.cancelled),
)
@@ -137,17 +139,27 @@ export const make = Effect.fnUntraced(function* (input: {
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
case "ChildUpdate":
return input.connection
.extNotification(ACPTranslate.ChildSessionUpdateMethod, output.update)
.pipe(Effect.ignoreCause)
.extNotification(ACPChild.UpdateMethod, output.update)
.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logWarning("ACP child session update failed", cause),
),
)
case "PermissionAsk":
return Queue.offer(subscription.asks, reply(subscription, ctx, output)).pipe(Effect.asVoid)
return Effect.gen(function* () {
const settled = yield* Deferred.make<void>()
subscription.settled.set(output.event.data.id, settled)
yield* Queue.offer(subscription.asks, reply(subscription, ctx, output, settled))
})
case "FormAsk":
return Effect.gen(function* () {
const capabilities = yield* Ref.get(input.capabilities)
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
const settled = yield* Deferred.make<void>()
subscription.forms.set(output.form.id, settled)
subscription.settled.set(output.form.id, settled)
yield* Queue.offer(
subscription.asks,
ACPElicitation.reply(
@@ -165,10 +177,10 @@ export const make = Effect.fnUntraced(function* (input: {
),
)
})
case "FormSettled":
case "AskSettled":
return Effect.suspend(() => {
const settled = subscription.forms.get(output.formID)
subscription.forms.delete(output.formID)
const settled = subscription.settled.get(output.id)
subscription.settled.delete(output.id)
return settled ? Deferred.succeed(settled, undefined) : Effect.void
})
}
@@ -181,8 +193,10 @@ export const make = Effect.fnUntraced(function* (input: {
) {
while (true) {
const event = yield* take(subscription)
const next = ACPTranslate.step(yield* Ref.get(state), event, ctx)
yield* Ref.set(state, next.state)
const next = yield* Ref.modify(state, (current) => {
const step = ACPTranslate.step(current, event, ctx)
return [step, step.state]
})
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
if (next.terminal) {
yield* asksSettled(subscription)
@@ -226,19 +240,22 @@ export const make = Effect.fnUntraced(function* (input: {
.pipe(Effect.catch(ACPClient.classify))
})
const interruptServer = (sessionID: Session.ID) =>
input.client.session.interrupt({ sessionID }).pipe(Effect.ignoreCause)
const windDown = Effect.fnUntraced(function* (
subscription: Subscription,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
events: Fiber.Fiber<ACPTranslate.Terminal, Failure>,
events: Fiber.Fiber<ACPTranslate.Terminal, ACPError.Failure>,
) {
yield* Deferred.succeed(subscription.cancelled, undefined)
yield* interruptServer(ctx.sessionID)
yield* input.client.session
.interrupt({ sessionID: ctx.sessionID })
.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP server interrupt failed", cause),
),
)
if (!(yield* Ref.get(state)).started) return
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
if (Option.exists(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)), Exit.isSuccess)) return
yield* Fiber.interrupt(events)
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
yield* Ref.set(state, abandoned.state)
@@ -276,7 +293,7 @@ export const make = Effect.fnUntraced(function* (input: {
subscription: Subscription,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
) {
const close = Scope.close(subscription.scope, Exit.void)
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
@@ -294,7 +311,7 @@ export const make = Effect.fnUntraced(function* (input: {
const settle = Effect.fnUntraced(function* (
attached: Attached,
state: Ref.Ref<ACPTranslate.TurnState>,
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
) {
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
const current = yield* Ref.get(state)
@@ -323,7 +340,9 @@ export const make = Effect.fnUntraced(function* (input: {
},
})
},
(effect) => Effect.ignoreCause(effect),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP usage update failed", cause),
),
)
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
@@ -342,8 +361,8 @@ export const make = Effect.fnUntraced(function* (input: {
return yield* settle(attached, state, exit)
})
return Service.of({
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
return {
prompt: Effect.fnUntraced(function* (params, signal) {
const attached = yield* input.sessions.require(params.sessionId)
const catalog = yield* input.catalog.get(attached.cwd)
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
@@ -371,7 +390,7 @@ export const make = Effect.fnUntraced(function* (input: {
yield* aborted(signal).pipe(Effect.andThen(Fiber.interrupt(turn)), Effect.forkChild)
return yield* Fiber.join(turn)
}),
cancel: Effect.fn("cli.acp.turn.cancel")(function* (params) {
cancel: Effect.fnUntraced(function* (params) {
yield* FiberMap.remove(turns, params.sessionId)
}),
close: Effect.fn("cli.acp.turn.close")(function* (sessionID) {
@@ -382,7 +401,7 @@ export const make = Effect.fnUntraced(function* (input: {
Effect.catch(ACPClient.classify),
)
}),
})
} satisfies Interface
})
function aborted(signal: AbortSignal) {
@@ -401,8 +420,17 @@ function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
const slash = detectSlashCommand(text)
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
const start = turnStart(messageID, slash)
return { start, text, files, synthetic, slash, command }
return {
start:
slash && builtinCommands.get(slash.name)?.start === "compaction"
? { type: "compaction", id: messageID }
: { type: "input", id: messageID },
text,
files,
synthetic,
slash,
command,
}
}
function referenceUnreadableFile(part: PromptPart) {
@@ -413,12 +441,7 @@ function referenceUnreadableFile(part: PromptPart) {
)
}
function turnStart(messageID: SessionMessage.ID, slash: PreparedPrompt["slash"]): ACPTranslate.TurnStart {
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
return { type: "input", id: messageID }
}
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
function detectSlashCommand(text: string) {
const value = text.trim()
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
@@ -1,6 +1,6 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit } from "effect"
import { OpenCode, type PluginInfo } from "@opencode/client"
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Npm } from "@opencode/util/npm"
import { Config } from "../../../config"
@@ -1,5 +1,4 @@
import { EOL } from "node:os"
import path from "node:path"
import { readFile, rename, writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Service, type Endpoint } from "@opencode/client/effect/service"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode/client/promise"
import { OpenCode, type SessionMessageAssistantTool } from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import { open } from "node:fs/promises"
import path from "node:path"
+5 -1
View File
@@ -49,7 +49,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
inherited === undefined
? undefined
: yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PersistentPty.Handoff))(inherited).pipe(
Effect.mapError(() => new Error("Invalid PTY restart handoff")),
Effect.catch(() =>
Effect.logWarning("Ignoring invalid PTY restart handoff; persistent terminals will start fresh").pipe(
Effect.as(undefined),
),
),
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
-1
View File
@@ -1,4 +1,3 @@
import { Service } from "@opencode/client/effect/service"
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Deferred, Effect, Schema, Stream } from "effect"
@@ -1,8 +1,6 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import type { Permission } from "@opencode/schema/permission"
import { tmpdir } from "../fixture/tmpdir"
import { makeSession, rpcError, startWire, type Wire } from "./wire-fixture"
const key = "opencode.acp.additionalDirectories"
@@ -28,56 +26,6 @@ const updates = (acp: Wire) =>
acp.server.requests.filter((request) => request.method === "PATCH" && request.path.startsWith("/api/session/"))
describe("acp additional directories over the wire", () => {
test("session/new grants normalized unique directories other than cwd and lists them", async () => {
await using acp = await startWire()
await acp.initialize()
const created = await acp.request("session/new", {
cwd: "/workspace",
additionalDirectories: ["/shared/lib/", "/workspace", "/docs/../product-docs", "/shared/lib", "/workspace/"],
mcpServers: [],
})
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
permissions: [grant(sharedLib), grant(productDocs)],
metadata: { [key]: [sharedLib, productDocs] },
})
expect((await acp.request("session/list", { cwd: "/workspace" })).sessions).toEqual([
expect.objectContaining({
sessionId: created.sessionId,
additionalDirectories: [sharedLib, productDocs],
}),
])
})
test("grants both the written and real spelling of a symlinked root and drops links to cwd", async () => {
await using tmp = await tmpdir()
const root = await fs.realpath(tmp.path)
const cwd = path.join(root, "workspace")
const shared = path.join(root, "shared")
await Promise.all([fs.mkdir(cwd), fs.mkdir(shared)])
await Promise.all([
fs.symlink(shared, path.join(root, "shared-link")),
fs.symlink(cwd, path.join(root, "workspace-link")),
])
await using acp = await startWire()
await acp.initialize()
const created = await acp.request("session/new", {
cwd,
additionalDirectories: [path.join(root, "workspace-link"), path.join(root, "shared-link")],
mcpServers: [],
})
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
permissions: [grant(path.join(root, "shared-link")), grant(shared)],
metadata: { [key]: [path.join(root, "shared-link")] },
})
expect((await acp.request("session/list", { cwd })).sessions[0]?.additionalDirectories).toEqual([
path.join(root, "shared-link"),
])
})
test.each(["shared/lib", "", "/shared/*", "/shared/lib?"])(
"rejects %p before creating a session",
async (directory) => {
@@ -155,28 +103,4 @@ describe("acp additional directories over the wire", () => {
})
expect(acp.server.sessions.get("ses_source")?.permissions).toEqual([grant(old), ...other])
})
test("leaves sessions alone without additional directories", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_saved", {
...makeSession("ses_saved"),
metadata: { host: "tui" },
permissions: [userGrant, ...other],
})
await acp.initialize()
const created = await acp.request("session/new", { cwd: "/workspace", mcpServers: [] })
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers: [] })
await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_saved", additionalDirectories: [] })
const create = acp.server.requests.find((request) => request.method === "POST" && request.path === "/api/session")
expect(create?.body).toMatchObject({ location: { directory: "/workspace" }, permissions: null, metadata: null })
expect(acp.server.sessions.get(created.sessionId)?.permissions).toBeUndefined()
expect(updates(acp)).toEqual([])
expect(
(await acp.request("session/list", { cwd: "/workspace" })).sessions.map(
(session) => session.additionalDirectories,
),
).toEqual([undefined, undefined])
})
})
@@ -1,84 +0,0 @@
import type { CloseSessionResponse, PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
// The mock model holds any completion whose request mentions "hold" until the test releases it,
// so cancellation can be exercised while the model turn is genuinely in flight.
function heldModel() {
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<string>()
return {
started: started.promise,
release: () => release.resolve("released"),
respond(request: unknown) {
if (!JSON.stringify(request).includes("hold")) return "accepted"
started.resolve()
return release.promise
},
}
}
describe("acp cancel subprocess", () => {
test("$/cancel_request cancels the in-flight prompt and the session stays usable", async () => {
const model = heldModel()
await using fixture = await createAcpFixture({ respond: model.respond })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const prompt = acp.send<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hold" }],
})
await model.started
await acp.notify("$/cancel_request", { requestId: prompt.id })
expect(expectOk(await prompt.response).stopReason).toBe("cancelled")
model.release()
const next = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "again" }],
}),
)
expect(next.stopReason).toBe("end_turn")
}, 60_000)
test("session/close settles the active prompt before responding and leaves other sessions alone", async () => {
const model = heldModel()
await using fixture = await createAcpFixture({ respond: model.respond })
const acp = fixture.spawn()
await initialize(acp)
const closing = await newSession(acp, fixture.home)
const other = await newSession(acp, fixture.home)
const prompt = acp.send<PromptResponse>("session/prompt", {
sessionId: closing.sessionId,
prompt: [{ type: "text", text: "hold" }],
})
await model.started
const order: string[] = []
const close = acp
.request<CloseSessionResponse>("session/close", { sessionId: closing.sessionId })
.then((response) => {
order.push("close")
return response
})
const cancelled = await prompt.response.then((response) => {
order.push("prompt")
return response
})
expect(expectOk(cancelled).stopReason).toBe("cancelled")
expect(expectOk(await close)).toEqual({})
expect(order).toEqual(["prompt", "close"])
model.release()
const next = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: other.sessionId,
prompt: [{ type: "text", text: "still here" }],
}),
)
expect(next.stopReason).toBe("end_turn")
}, 60_000)
})
@@ -1,131 +0,0 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode/schema/agent"
import { Clock, Duration, Effect, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { it } from "../../../core/test/lib/effect"
import { ACPCatalog } from "../../src/acp/catalog"
import { buildAgent, makeClient, planAgent, startWire, testModel, type Wire, type WireOptions } from "./wire-fixture"
describe("acp catalog service", () => {
it.effect("coalesces reloads requested during a reload into one more load", () => {
const gate = { held: false, release: Promise.withResolvers<void>() }
return withCatalog(
{
fetch: (request) =>
request.path === "/api/agent" && gate.held ? gate.release.promise.then(() => undefined) : undefined,
},
(acp) =>
Effect.gen(function* () {
const catalog = yield* ACPCatalog.Service
yield* catalog.get("/workspace")
gate.held = true
const running = yield* catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.promise(() => acp.until(() => requests(acp, "/api/agent") === 2, "the held reload"))
const queued = yield* Effect.all(
[0, 1].map(() => catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))),
)
acp.server.catalog.agents = [planAgent, buildAgent]
gate.held = false
gate.release.resolve()
yield* Fiber.join(running)
yield* Fiber.joinAll(queued)
expect(reads(acp, "agent")).toBe(3)
expect((yield* catalog.get("/workspace")).defaultModeID).toBe(Agent.ID.make("plan"))
}),
)
})
it.effect("keeps the previous catalog when a reload fails", () => {
const failing = { model: false }
return withCatalog(
{
fetch: (request) =>
failing.model && request.path === "/api/model"
? Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
: undefined,
},
() =>
Effect.gen(function* () {
const catalog = yield* ACPCatalog.Service
const before = yield* catalog.get("/workspace")
failing.model = true
yield* catalog.reload("/workspace")
expect(yield* catalog.get("/workspace")).toBe(before)
}),
)
})
it.effect("waits 25ms between readiness reads", () =>
withCatalog({}, (acp) =>
Effect.gen(function* () {
const catalog = yield* ACPCatalog.Service
acp.server.catalog.models = []
const loading = yield* catalog.get("/workspace").pipe(Effect.forkChild)
yield* Effect.promise(() => acp.until(() => reads(acp, "model") === 1, "the first model read"))
acp.server.catalog.models = [testModel]
yield* advance("5 millis", () => reads(acp, "model") === 2)
const retriedAt = yield* Clock.currentTimeMillis
const loaded = yield* Fiber.join(loading)
expect(retriedAt).toBeGreaterThanOrEqual(25)
expect(loaded.defaultModel).toMatchObject({ providerID: "test", id: "test-model", variant: "default" })
}),
),
)
it.effect("gives up with the last readiness failure after 5 seconds", () =>
withCatalog({}, (acp) =>
Effect.gen(function* () {
const catalog = yield* ACPCatalog.Service
acp.server.catalog.agents = []
const loading = yield* catalog.get("/workspace").pipe(Effect.flip, Effect.timed, Effect.forkChild)
yield* advance("25 millis", () => loading.pollUnsafe() !== undefined)
const [elapsed, error] = yield* Fiber.join(loading)
expect(error).toEqual(new ACPCatalog.NotReadyError({ reason: "agents" }))
expect(error.message).toBe("No primary agents are available")
// Reads in flight while the clock steps push the last attempt slightly past the deadline.
expect(Duration.toMillis(elapsed)).toBeGreaterThanOrEqual(5_000)
expect(Duration.toMillis(elapsed)).toBeLessThan(6_000)
}),
),
)
})
function withCatalog<A, E>(options: WireOptions, body: (acp: Wire) => Effect.Effect<A, E, ACPCatalog.Service>) {
return Effect.acquireRelease(
Effect.promise(() => startWire(options)),
(acp) => Effect.promise(() => acp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((acp) =>
body(acp).pipe(
Effect.provideServiceEffect(
ACPCatalog.Service,
makeClient(acp.server.url).pipe(Effect.flatMap(ACPCatalog.make)),
),
),
),
)
}
// Catalog reads are real HTTP that settles between sleeps, so the clock moves in steps until the reads catch up.
function advance(step: Duration.Input, done: () => boolean) {
return TestClock.adjust(step).pipe(
Effect.andThen(TestClock.withLive(Effect.sleep("1 millis"))),
Effect.repeat({ until: done }),
)
}
function reads(acp: Wire, kind: "model" | "agent") {
return acp.server.catalogReads.filter((read) => read.kind === kind).length
}
function requests(acp: Wire, path: string) {
return acp.server.requests.filter((request) => request.path === path).length
}
+2 -300
View File
@@ -1,230 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption, SessionNotification } from "@agentclientprotocol/sdk"
import { currentValue, requireSelectOption, selectValues } from "./select-options"
import {
buildAgent,
ephemeralEvent,
planAgent,
reviewCommand,
rpcError,
secondModel,
startSession,
startWire,
testModel,
type Wire,
} from "./wire-fixture"
import { currentValue } from "./select-options"
import { rpcError, secondModel, startSession } from "./wire-fixture"
describe("acp catalog and config options over the wire", () => {
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
await using acp = await startWire()
await acp.initialize()
const first = await Promise.all([acp.newSession("/workspace"), acp.newSession("/workspace")])
const other = await acp.newSession("/other")
expect(currentValue(first[0], "model")).toBe("test/test-model")
expect(currentValue(first[0], "mode")).toBe("build")
expect(
(["model", "default", "agent", "command"] as const).map((kind) =>
acp.server.catalogReads.filter((read) => read.kind === kind).map((read) => read.directory),
),
).toEqual(Array.from({ length: 4 }, () => ["/workspace", "/other"]))
expect(
Object.fromEntries([...acp.server.sessions.values()].map((session) => [session.id, session.location.directory])),
).toEqual({
[first[0].sessionId]: "/workspace",
[first[1].sessionId]: "/workspace",
[other.sessionId]: "/other",
})
await acp.until(() => acp.updates.filter((item) => commandNames(item)).length === 3, "commands for each session")
expect(acp.updates.map(commandNames)).toEqual(Array.from({ length: 3 }, () => ["review", "compact"]))
})
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
const configured = { ...buildAgent, id: "copilot-build", name: "copilot-build" }
await using acp = await startSession()
expect(currentValue(acp.session, "mode")).toBe("build")
expect(currentValue(acp.session, "model")).toBe("test/test-model")
const reads = agentReads(acp)
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/other" }))
acp.server.catalog.agents = [configured, buildAgent, planAgent]
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
const update = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
expect(update.update.sessionUpdate === "config_option_update" && modeOption(update.update.configOptions)).toEqual({
currentValue: "copilot-build",
options: ["copilot-build", "build", "plan"],
})
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 3)
expect(commandNames(commands)).toEqual(["review", "ship", "compact"])
expect(agentReads(acp)).toBe(reads + 1)
const second = await acp.newSession()
expect(currentValue(second, "mode")).toBe("copilot-build")
})
test("defaults the mode to the first selectable agent the server lists", async () => {
const configured = { ...buildAgent, id: "review", name: "Review", mode: "all" as const }
await using acp = await startWire()
acp.server.catalog.agents = [configured, buildAgent, planAgent]
await acp.initialize()
const session = await acp.newSession()
expect(modeOption(session.configOptions ?? [])).toEqual({
currentValue: "review",
options: ["review", "build", "plan"],
})
})
test("pushes config options on model.updated and commands on command.updated", async () => {
await using acp = await startWire()
acp.server.catalog.models = [testModel]
await acp.initialize()
const session = await acp.newSession()
expect(selectValues(session.configOptions, "model")).toEqual(["test/test-model"])
acp.server.catalog.models = [testModel, secondModel]
acp.server.send(ephemeralEvent("model.updated", {}))
const options = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
expect(
options.update.sessionUpdate === "config_option_update" && selectValues(options.update.configOptions, "model"),
).toEqual(["test/second-model", "test/test-model"])
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 3)
expect(commands).toEqual({
sessionId: session.sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "ship", description: "Ship it" },
{ name: "compact", description: "Compact the session" },
],
},
})
expect(acp.updates.filter((item) => item.update.sessionUpdate === "config_option_update")).toHaveLength(1)
})
test("reloads the catalog before rejecting a model or mode it has not seen", async () => {
const configured = { ...planAgent, id: "copilot-build", name: "copilot-build" }
await using acp = await startWire()
acp.server.catalog.models = [testModel]
await acp.initialize()
const session = await acp.newSession()
acp.server.catalog.models = [testModel, secondModel]
acp.server.catalog.agents = [buildAgent, planAgent, configured]
const set = (configId: string, value: string) =>
acp.request("session/set_config_option", { sessionId: session.sessionId, configId, value })
const initialModelReads = modelReads(acp)
const model = await set("model", "test/second-model")
const reloadedModelReads = modelReads(acp)
const missingModel = await rpcError(set("model", "test/missing-model"))
const missingModelReads = modelReads(acp)
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
const reads = agentReads(acp)
const missing = await rpcError(set("mode", "missing"))
expect(currentValue(model, "model")).toBe("test/second-model")
expect([reloadedModelReads, missingModelReads]).toEqual([initialModelReads + 1, initialModelReads + 2])
expect(missingModel).toMatchObject({ code: -32602, data: { modelId: "test/missing-model" } })
expect(acp.server.selections).toContainEqual({ sessionID: session.sessionId, agent: "copilot-build" })
expect(missing).toMatchObject({ code: -32602, data: { mode: "missing" } })
expect(agentReads(acp)).toBeGreaterThan(reads)
})
test.each([
[
"a sibling session closes",
async (acp: Wire) => {
const closed = await acp.newSession()
const open = await acp.newSession()
await acp.request("session/close", { sessionId: closed.sessionId })
await initialCommands(acp, open.sessionId, 1)
return open.sessionId
},
],
...(["session/load", "session/resume"] as const).map(
(method) =>
[
`${method} re-attaches the session`,
async (acp: Wire) => {
const session = await acp.newSession()
const params = { cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] }
await acp.request(method, params)
await acp.request(method, params)
await initialCommands(acp, session.sessionId, 3)
return session.sessionId
},
] as const,
),
])("pushes exactly one update per catalog change after %s", async (_, setup) => {
await using acp = await startWire()
acp.server.catalog.models = [testModel]
await acp.initialize()
const sessionId = await setup(acp)
const since = acp.updates.length
await change(acp, sessionId, "config_option_update", () => {
acp.server.catalog.models = [testModel, secondModel]
acp.server.send(ephemeralEvent("model.updated", {}))
})
await change(acp, sessionId, "available_commands_update", () => {
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
})
expect(updateKinds(acp, since)).toEqual([
[sessionId, "config_option_update"],
[sessionId, "available_commands_update"],
])
})
test.each(["empty", "missing the default"])(
"retries when the model list is %s but the default is ready",
async (initial) => {
await using acp = await startWire()
acp.server.catalog.models = initial === "empty" ? [] : [secondModel]
acp.server.catalog.defaultModel = testModel
await acp.initialize()
const created = acp.newSession()
await acp.until(() => modelReads(acp) === 1, "first model read")
acp.server.catalog.models = [testModel, secondModel]
const session = await created
const choices = selectValues(session.configOptions, "model")
expect(choices).toContain("test/second-model")
expect(choices).toContain("test/test-model")
expect(currentValue(session, "model")).toBe("test/test-model")
expect(modelReads(acp)).toBe(2)
},
)
test("does not cache a failed catalog load", async () => {
const failure = { pending: true }
await using acp = await startWire({
fetch(request) {
if (request.path !== "/api/model" || !failure.pending) return undefined
failure.pending = false
return Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
},
})
await acp.initialize()
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603 })
expect(acp.server.sessions.size).toBe(0)
const retried = await acp.newSession()
expect(acp.server.sessions.has(retried.sessionId)).toBe(true)
expect(modelReads(acp)).toBe(1)
})
test("switches model, effort, and mode against the warm catalog", async () => {
await using acp = await startSession()
const sessionId = acp.sessionId
@@ -246,7 +24,6 @@ describe("acp catalog and config options over the wire", () => {
{ sessionID: sessionId, agent: "plan" },
{ sessionID: sessionId, agent: "build" },
])
expect(modelReads(acp)).toBe(1)
expect(await rpcError(set("effort", "maximum"))).toMatchObject({ code: -32602, data: { effort: "maximum" } })
expect(await rpcError(set("mode", "missing"))).toMatchObject({ code: -32602, data: { mode: "missing" } })
@@ -256,79 +33,4 @@ describe("acp catalog and config options over the wire", () => {
data: { modelId: "test/missing-model" },
})
})
test("advertises and runs the built-in compact over a server command (https://github.com/anomalyco/opencode/issues/37229)", async () => {
await using acp = await startSession()
const advertised = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
acp.server.catalog.commands = [
reviewCommand,
{ name: "compact", description: "Server compact" },
{ name: "ship", description: "Ship it" },
]
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
const replaced = await acp.waitForUpdate((item) => item !== advertised && commandNames(item) !== undefined)
const compacted = await acp.prompt(acp.sessionId, "/compact")
expect([advertised, replaced].map((item) => item.update)).toEqual([
{
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "compact", description: "Compact the session" },
],
},
{
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "ship", description: "Ship it" },
{ name: "compact", description: "Compact the session" },
],
},
])
expect(compacted.stopReason).toBe("end_turn")
expect(acp.server.submissions.map((item) => item.kind)).toEqual(["compact"])
})
})
// Each change waits on a catalog reload over HTTP, so a stray update for an earlier change lands before the next one.
async function change(acp: Wire, sessionId: string, kind: string, trigger: () => void) {
const seen = acp.updates.filter((item) => item.sessionId === sessionId && item.update.sessionUpdate === kind).length
trigger()
await acp.until(
() =>
acp.updates.filter((item) => item.sessionId === sessionId && item.update.sessionUpdate === kind).length > seen,
kind,
)
}
// Each attach sends its commands after the response, so later counts start once every attach has sent them.
function initialCommands(acp: Wire, sessionId: string, attaches: number) {
return acp.until(
() => acp.updates.filter((item) => item.sessionId === sessionId && commandNames(item)).length === attaches,
"initial commands",
)
}
function updateKinds(acp: Wire, since: number) {
return acp.updates.slice(since).map((item) => [item.sessionId, item.update.sessionUpdate])
}
function commandNames(item: SessionNotification) {
if (item.update.sessionUpdate !== "available_commands_update") return undefined
return item.update.availableCommands.map((command) => command.name)
}
function agentReads(acp: Wire) {
return acp.server.catalogReads.filter((read) => read.kind === "agent").length
}
function modelReads(acp: Wire) {
return acp.server.catalogReads.filter((read) => read.kind === "model").length
}
function modeOption(options: SessionConfigOption[]) {
const mode = requireSelectOption(options, "mode")
return { currentValue: mode.currentValue, options: selectValues(options, "mode") }
}
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
describe("acp command", () => {
test("is registered", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toMatch(/^ acp[ \t]+Start an Agent Client Protocol server\r?$/m)
})
})
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, "../.."),
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
+4 -603
View File
@@ -1,201 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { CompactionUpdate, SessionNotification } from "@agentclientprotocol/sdk"
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
import { SessionMessage } from "@opencode/schema/session-message"
import { Effect, Exit, Queue, Schema, Scope, Stream } from "effect"
import {
assistantMessage,
childCreated,
delivered,
durableEvent,
enqueued,
ephemeralEvent,
makeClient,
interrupted,
makeSession,
startSession,
startWire,
stepEnded,
succeeded,
textDelta,
turn,
type InitializeOptions,
type Wire,
} from "./wire-fixture"
import { Schema } from "effect"
import { durableEvent, enqueued, ephemeralEvent, startSession, turn, type InitializeOptions } from "./wire-fixture"
const summary = "Summary of the earlier conversation"
const providerError = { type: "provider.error", message: "summary request failed", status: 500 }
const decodeCompact = Schema.decodeUnknownSync(Schema.Struct({ id: Schema.String }))
const standard: InitializeOptions = { compaction: {} }
describe("acp compaction markers over the wire", () => {
test("marks a /compact turn without forwarding the summary text", async () => {
const compacted = await compactTurn((sessionID, id) => [
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
])
await using acp = compacted.acp
expect(turnUpdates(acp.updates)).toEqual([
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
marker(acp.sessionId, { status: "completed", messageId: compacted.id, reason: "manual" }),
])
expect(compacted.response.stopReason).toBe("end_turn")
})
test("marks a failed /compact turn with the full compaction error", async () => {
const compacted = await compactTurn((sessionID, id) => [
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
durableEvent("session.compaction.failed", { sessionID, reason: "manual", inputID: id, error: providerError }),
])
await using acp = compacted.acp
expect(turnUpdates(acp.updates)).toEqual([
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
marker(acp.sessionId, { status: "failed", messageId: compacted.id, reason: "manual", error: providerError }),
])
expect(compacted.response.stopReason).toBe("end_turn")
})
test("marks an automatic compaction between steps with the ID its replayed message gets", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
textDelta(sessionID, "msg_before", "before"),
stepEnded(sessionID, "msg_before"),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
textDelta(sessionID, "msg_after", "after"),
stepEnded(sessionID, "msg_after"),
),
})
using events = await watchEvents(acp.server.url)
const response = await acp.prompt(acp.sessionId, "hello")
const messageId = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
chunk(acp.sessionId, "msg_before", "before"),
marker(acp.sessionId, { status: "started", messageId, reason: "auto" }),
marker(acp.sessionId, { status: "completed", messageId, reason: "auto" }),
chunk(acp.sessionId, "msg_after", "after"),
])
expect(response.stopReason).toBe("end_turn")
const live = turnUpdates(acp.updates)
acp.server.messages.set(acp.sessionId, [
{
id: messageId,
type: "compaction",
status: "completed",
reason: "auto",
summary,
recent: "",
time: { created: 1 },
},
])
await acp.request("session/load", { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] })
expect(turnUpdates(acp.updates).slice(live.length)).toEqual([
marker(acp.sessionId, { status: "completed", messageId, reason: "auto" }),
])
})
test("marks automatic compaction failures before and after the compaction starts", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.failed", {
sessionID,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
}),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const unstarted = await events.messageID("session.compaction.failed")
const started = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
marker(acp.sessionId, {
status: "failed",
messageId: unstarted,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
}),
marker(acp.sessionId, { status: "started", messageId: started, reason: "auto" }),
marker(acp.sessionId, { status: "failed", messageId: started, reason: "auto", error: providerError }),
])
})
test("projects child session compaction markers onto the parent turn", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
durableEvent("session.compaction.ended", {
sessionID: "ses_child",
reason: "auto",
text: summary,
recent: "",
}),
succeeded("ses_child"),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const messageId = await events.messageID("session.compaction.started")
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
])
})
test("replays settled compactions at their position on session/load", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
acp.server.messages.set("ses_compacted", compactedHistory())
await acp.initialize()
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
expect(turnUpdates(acp.updates)).toEqual([
{
sessionId: "ses_compacted",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_user",
content: { type: "text", text: "hello" },
},
},
marker("ses_compacted", {
status: "failed",
messageId: "msg_compaction_failed",
reason: "auto",
error: providerError,
}),
marker("ses_compacted", { status: "completed", messageId: "msg_compaction", reason: "manual" }),
chunk("ses_compacted", "msg_after", "after"),
])
})
})
describe("acp standard compaction updates over the wire", () => {
test("streams a /compact turn's summary and completes it with the full summary", async () => {
@@ -206,7 +16,7 @@ describe("acp standard compaction updates over the wire", () => {
ephemeralEvent("session.compaction.delta", { sessionID, text: "the earlier conversation" }),
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
],
standard,
{ compaction: {} },
)
await using acp = compacted.acp
@@ -223,343 +33,6 @@ describe("acp standard compaction updates over the wire", () => {
expect(compacted.response.stopReason).toBe("end_turn")
})
test("completes a compaction without a readable summary with a cleared summary", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: "", recent: "" }),
textDelta(sessionID, "msg_after", "after"),
stepEnded(sessionID, "msg_after"),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
compaction(acp.sessionId, { compactionId, status: "completed", summary: null }),
chunk(acp.sessionId, "msg_after", "after"),
])
})
test("fails a compaction with its error and clears the partial summary", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
summaryChunk(acp.sessionId, compactionId, "partial"),
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: providerError.message }),
])
})
test("opens a compaction that fails before it starts before settling it", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.failed", {
sessionID,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
}),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.failed")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: "Nothing to compact yet" }),
])
})
test("reports a defect as a generic failure and keeps core's own failure messages", async () => {
const limit = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
const defect = { type: "compaction.failed", message: "Error: database is locked\n at run (compaction.ts:1:1)" }
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: limit }),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: defect }),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const first = await events.messageID("session.compaction.started")
const second = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId: first, status: "in_progress" }),
compaction(acp.sessionId, { compactionId: first, status: "failed", summary: null, error: limit.message }),
compaction(acp.sessionId, { compactionId: second, status: "in_progress" }),
compaction(acp.sessionId, { compactionId: second, status: "failed", summary: null, error: "Compaction failed" }),
])
})
test("drops summary chunks outside an open compaction", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
ephemeralEvent("session.compaction.delta", { sessionID, text: "early" }),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "late" }),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
compaction(acp.sessionId, { compactionId, status: "completed", summary: [{ type: "text", text: summary }] }),
])
})
test("cancels a compaction the server interrupts during a cancelled turn's wind-down", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
],
onInterrupt: ({ sessionID }) => [
durableEvent("session.compaction.failed", {
sessionID,
reason: "auto",
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}),
interrupted(sessionID),
],
})
using events = await watchEvents(acp.server.url)
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_update")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await prompt).stopReason).toBe("cancelled")
const compactionId = await events.messageID("session.compaction.started")
expect(updatesBeforeResponse(acp)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
])
})
test("cancels a compaction left open when the wind-down never reports it", async () => {
const submitted: string[] = []
await using acp = await startSession({
capabilities: standard,
cancelDrainTimeout: "50 millis",
onPrompt: ({ sessionID, id }) => {
submitted.push(id)
return [
delivered(sessionID, id),
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
]
},
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_summary_chunk")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await prompt).stopReason).toBe("cancelled")
const compactionId = submitted[0]
expect(updatesBeforeResponse(acp)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
summaryChunk(acp.sessionId, compactionId, "partial").update,
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
])
})
test("closes the marker of a compaction left open when the wind-down never reports it", async () => {
const submitted: string[] = []
await using acp = await startSession({
cancelDrainTimeout: "50 millis",
onPrompt: ({ sessionID, id }) => {
submitted.push(id)
return [
delivered(sessionID, id),
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
]
},
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await prompt).stopReason).toBe("cancelled")
const messageId = submitted[0]
expect(updatesBeforeResponse(acp)).toEqual([
marker(acp.sessionId, { status: "started", messageId, reason: "manual" }).update,
marker(acp.sessionId, {
status: "failed",
messageId,
reason: "manual",
error: { type: "aborted", message: "Compaction cancelled" },
}).update,
])
})
test("keeps child compactions projected onto the parent as markers", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
durableEvent("session.compaction.ended", {
sessionID: "ses_child",
reason: "auto",
text: summary,
recent: "",
}),
succeeded("ses_child"),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const messageId = await events.messageID("session.compaction.started")
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
])
})
test("sends child compactions as standard updates through child session updates", async () => {
await using acp = await startSession({
capabilities: { ...standard, childSessionUpdates: true },
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
durableEvent("session.compaction.ended", {
sessionID: "ses_child",
reason: "auto",
text: summary,
recent: "",
}),
succeeded("ses_child"),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.started")
const child = { "opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" } }
expect(acp.childUpdates.flatMap((item) => (item.type === "update" ? [item.update] : []))).toEqual([
{ sessionUpdate: "compaction_update", compactionId, status: "in_progress", _meta: child },
{
sessionUpdate: "compaction_update",
compactionId,
status: "completed",
summary: [{ type: "text", text: summary }],
_meta: child,
},
])
expect(turnUpdates(acp.updates)).toEqual([])
})
test("replays settled compactions as terminal updates at their position on session/load", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
acp.server.messages.set("ses_compacted", [
...compactedHistory().slice(0, 3),
{
id: "msg_compaction_cancelled",
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "aborted", message: "Compaction cancelled" },
time: { created: 3 },
},
{
id: "msg_compaction_native",
type: "compaction",
status: "completed",
reason: "auto",
summary: "",
recent: "",
time: { created: 3 },
},
...compactedHistory().slice(3),
])
await acp.initialize(standard)
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
expect(turnUpdates(acp.updates)).toEqual([
{
sessionId: "ses_compacted",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_user",
content: { type: "text", text: "hello" },
},
},
compaction("ses_compacted", {
compactionId: "msg_compaction_failed",
status: "failed",
summary: null,
error: providerError.message,
}),
compaction("ses_compacted", {
compactionId: "msg_compaction",
status: "completed",
summary: [{ type: "text", text: summary }],
}),
compaction("ses_compacted", { compactionId: "msg_compaction_cancelled", status: "cancelled", summary: null }),
compaction("ses_compacted", { compactionId: "msg_compaction_native", status: "completed", summary: null }),
chunk("ses_compacted", "msg_after", "after"),
])
})
test("treats a null compaction capability as unsupported", async () => {
const compacted = await compactTurn(
(sessionID, id) => [
@@ -581,7 +54,7 @@ describe("acp standard compaction updates over the wire", () => {
// Holds the compact response so the test can publish the turn's events while the request is in flight.
async function compactTurn(
events: (sessionID: string, id: string) => OpenCodeEventEncoded[],
capabilities?: InitializeOptions,
capabilities: InitializeOptions,
) {
const held = Promise.withResolvers<Response>()
const acp = await startSession({
@@ -599,27 +72,6 @@ async function compactTurn(
return { acp, id, response: await response }
}
// Core derives an automatic compaction's message ID from the event ID the server stamps on publish.
async function watchEvents(url: string) {
const scope = Scope.makeUnsafe()
const events = await Effect.runPromise(
makeClient(url).pipe(
Effect.flatMap((client) => client.event.subscribe().pipe(Stream.toQueue({ capacity: "unbounded" }))),
Scope.provide(scope),
),
)
await Effect.runPromise(Queue.take(events))
return {
async messageID(type: OpenCodeEventEncoded["type"]) {
while (true) {
const event = await Effect.runPromise(Queue.take(events))
if (event.type === type) return SessionMessage.ID.fromEvent(event.id)
}
},
[Symbol.dispose]: () => Effect.runFork(Scope.close(scope, Exit.void)),
}
}
function marker(sessionId: string, value: Record<string, unknown>): SessionNotification {
return { sessionId, update: { sessionUpdate: "session_info_update", _meta: { "opencode/compaction": value } } }
}
@@ -635,59 +87,8 @@ function summaryChunk(sessionId: string, compactionId: string, text: string): Se
}
}
function chunk(sessionId: string, messageId: string, text: string): SessionNotification {
return { sessionId, update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text } } }
}
const isCancelledResponse = Schema.is(
Schema.Struct({ result: Schema.Struct({ stopReason: Schema.Literal("cancelled") }) }),
)
// Session updates the client received before the cancelled prompt response.
function updatesBeforeResponse(acp: Wire) {
const response = acp.received.findIndex(isCancelledResponse)
expect(response).toBeGreaterThan(-1)
const count = acp.received
.slice(0, response)
.filter((message) => "method" in message && message.method === "session/update").length
return turnUpdates(acp.updates.slice(0, count)).map((item) => item.update)
}
function turnUpdates(updates: readonly SessionNotification[]) {
return updates.filter(
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
)
}
function compactedHistory(): Array<typeof SessionMessage.Info.Encoded> {
return [
{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } },
{
id: "msg_compaction_failed",
type: "compaction",
status: "failed",
reason: "auto",
error: providerError,
time: { created: 2 },
},
{
id: "msg_compaction",
type: "compaction",
status: "completed",
reason: "manual",
summary,
recent: "",
time: { created: 3 },
},
{
id: "msg_compaction_running",
type: "compaction",
status: "running",
reason: "auto",
summary: "",
recent: "",
time: { created: 4 },
},
assistantMessage("msg_after", { time: { created: 5, completed: 6 }, content: [{ type: "text", text: "after" }] }),
]
}
+12 -115
View File
@@ -1,13 +1,5 @@
import { describe, expect, test } from "bun:test"
import {
buildConfigOptions,
buildEffortSelectOption,
buildModeSelectOption,
buildModelSelectOption,
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
} from "../../src/acp/config-option"
import { buildEffortSelectOption, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
@@ -22,26 +14,6 @@ const providers: ConfigOptionProvider[] = [
]
describe("acp config options", () => {
test("builds the model select option with ACP verifier category", () => {
expect(
buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
}),
).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("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",
@@ -56,96 +28,21 @@ describe("acp config options", () => {
{ 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("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[0]?.currentValue).toBe("anthropic/claude/sonnet-4")
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 variant names for display", () => {
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
test.each([
["openai/gpt-5", { model: { providerID: "openai", modelID: "gpt-5" } }],
["openai/gpt-5/low", { model: { providerID: "openai", modelID: "gpt-5" }, variant: "low" }],
["anthropic/claude/sonnet-4", { model: { providerID: "anthropic", modelID: "claude/sonnet-4" } }],
[
"anthropic/claude/sonnet-4/high",
{ model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, variant: "high" },
],
["anthropic/claude/sonnet-4/missing", { model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" } }],
])("parses the model selection %s, preferring exact slash-containing model ids", (value, expected) => {
expect(parseModelSelection(value, providers)).toEqual(expected)
})
})
@@ -1,84 +0,0 @@
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { alternateValue, flattenSelectOptions, requireSelectOption, selectConfigOption } from "./select-options"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
describe("acp config option subprocess", () => {
test('model option is listed with category "model"', async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const model = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "model")
expect(model.category).toBe("model")
expect(model.currentValue).toBe("test/test-model")
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
}, 60_000)
test("model switch updates currentValue", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const model = requireSelectOption(session.configOptions, "model")
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
expect(nextModel).toBe("test/second-model")
const updated = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}, 60_000)
test('effort option is listed with category "thought_level" when selected model supports variants', async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("default")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
}, 60_000)
test("effort survives model synchronization and can be reset to default", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const nextEffort = alternateValue(requireSelectOption(session.configOptions, "effort"))
const updated = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
const synchronized = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: requireSelectOption(session.configOptions, "model").currentValue,
}),
)
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
const reset = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: "default",
}),
)
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
}, 60_000)
})
-271
View File
@@ -1,271 +0,0 @@
import { describe, expect, test } from "bun:test"
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 only a data URL URI becomes a file part", () => {
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "data:image/png;base64,AAAA" }),
).toEqual([{ type: "file", url: "data:image/png;base64,AAAA", filename: "image", mime: "image/png" }])
})
test("image block with only an http URI becomes a markdown link", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/jpeg",
uri: "https://example.com/assets/photo.jpg",
}),
).toEqual([{ type: "text", text: "[photo.jpg](https://example.com/assets/photo.jpg)" }])
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/" }),
).toEqual([{ type: "text", text: "[image](https://example.com/)" }])
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "http://example.com/a.png" }),
).toEqual([{ type: "text", text: "[a.png](http://example.com/a.png)" }])
})
test("image block with only a URI of another scheme or a bare path becomes a markdown link", () => {
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "ftp://host/a.png" })).toEqual([
{ type: "text", text: "[a.png](ftp://host/a.png)" },
])
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "assets/b.png" })).toEqual([
{ type: "text", text: "[b.png](assets/b.png)" },
])
})
test("image block with only a file URI becomes a file part", () => {
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "file:///tmp/screenshot.png" }),
).toEqual([{ type: "file", url: "file:///tmp/screenshot.png", filename: "screenshot.png", mime: "image/png" }])
})
test("image block with only a zed path becomes a file part named after the path", () => {
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "zed://file?path=/tmp/shot.png" }),
).toEqual([{ type: "file", url: pathToFileURL("/tmp/shot.png").href, filename: "shot.png", mime: "image/png" }])
})
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 to another scheme becomes a markdown link", () => {
expect(contentBlockToParts({ type: "resource_link", uri: "https://example.com/spec", name: "spec" })).toEqual([
{ type: "text", text: "[spec](https://example.com/spec)" },
])
})
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 sourced text part", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt#L12-L14",
mimeType: "text/plain",
text: "context",
},
})
expect(result).toHaveLength(1)
expect(result[0]?.type).toBe("text")
if (result[0]?.type === "text") {
expect(result[0].text.endsWith("\ncontext")).toBe(true)
expect(result[0].text.includes("context.txt")).toBe(true)
expect(result[0].text.includes("12")).toBe(true)
}
})
test("resource with text uses URI fallback for non-file resources", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "mcp://server/context",
text: "context",
},
}),
).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
})
test("resource with text includes file path", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt",
mimeType: "text/plain",
text: "context",
},
})
expect(result).toHaveLength(1)
expect(result[0]?.type).toBe("text")
if (result[0]?.type === "text") {
expect(result[0].text.endsWith("\ncontext")).toBe(true)
expect(result[0].text.includes("context.txt")).toBe(true)
}
})
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([
// @ts-expect-error Exercise forward compatibility with an unknown ACP content block.
{ type: "unknown", text: "skip" },
]),
).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",
},
},
},
])
})
})
+3 -400
View File
@@ -1,12 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { AnyMessage, AnyRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk"
import type { CreateElicitationResponse } from "@agentclientprotocol/sdk"
import { ACPElicitation } from "../../src/acp/elicitation"
import {
childCreated,
delivered,
durableEvent,
ephemeralEvent,
interrupted,
startSession,
succeeded,
textDelta,
@@ -14,13 +11,13 @@ import {
toolSucceeded,
} from "./wire-fixture"
const questions = (sessionID: string, id = "frm_question", tool = "call_question") =>
const questions = (sessionID: string, id = "frm_question") =>
ephemeralEvent("form.created", {
form: {
id,
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "msg_tools", id: tool } },
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
fields: [
{
key: "q0",
@@ -57,130 +54,7 @@ const accept = (content: Record<string, string | number | boolean | string[]>):
content,
})
const pendingUntilAborted = (_request: unknown, signal: AbortSignal) =>
new Promise<CreateElicitationResponse>((resolve) => {
signal.addEventListener("abort", () => resolve({ action: "cancel" }), { once: true })
})
const firstElicitationCancel = (received: readonly AnyMessage[]): AnyMessage => {
const asked = received.find(
(message): message is AnyRequest =>
"method" in message && "id" in message && message.method === "elicitation/create",
)
return { jsonrpc: "2.0", method: "$/cancel_request", params: { requestId: asked?.id } }
}
describe("acp elicitation mapping", () => {
test("maps every representable field type", () => {
expect(
ACPElicitation.requestedSchema(
form([
{
key: "email",
title: "Email",
description: "Work address",
type: "string",
format: "email",
maxLength: 80,
pattern: ".+@.+",
placeholder: "you@example.com",
default: "a@b.co",
required: true,
},
{ key: "name", type: "string", minLength: 2 },
{
key: "plan",
type: "string",
options: [
{ value: "pro", label: "Pro", description: "Paid" },
{ value: "free", label: "Free" },
],
default: "free",
},
{ key: "ratio", type: "number", minimum: 0, maximum: 1, default: 0.5 },
{ key: "count", type: "integer", minimum: 1, required: true },
{ key: "confirm", type: "boolean", default: false },
{
key: "tags",
type: "multiselect",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
maxItems: 2,
default: ["a"],
required: true,
},
{ key: "server", type: "string", format: "uri", hidden: true, default: "https://example.com" },
]),
capable,
),
).toEqual({
type: "object",
properties: {
email: {
type: "string",
title: "Email",
description: "Work address",
format: "email",
minLength: 1,
maxLength: 80,
pattern: ".+@.+",
default: "a@b.co",
},
name: { type: "string", minLength: 2 },
plan: {
type: "string",
oneOf: [
{ const: "pro", title: "Pro", description: "Paid" },
{ const: "free", title: "Free" },
],
default: "free",
},
ratio: { type: "number", minimum: 0, maximum: 1, default: 0.5 },
count: { type: "integer", minimum: 1 },
confirm: { type: "boolean", default: false },
tags: {
type: "array",
items: {
anyOf: [
{ const: "a", title: "A" },
{ const: "b", title: "B" },
],
},
minItems: 1,
maxItems: 2,
default: ["a"],
},
},
required: ["email", "count", "tags"],
})
})
test("adds a free-text property next to options that accept a custom answer", () => {
expect(
ACPElicitation.requestedSchema(
form([
{
key: "q0",
title: "Runtime",
type: "string",
options: [{ value: "Bun", label: "Bun" }],
custom: true,
maxLength: 20,
},
{ key: "q1", type: "multiselect", options: [{ value: "Fast", label: "Fast" }], custom: true },
]),
capable,
)?.properties,
).toEqual({
q0: { type: "string", title: "Runtime", oneOf: [{ const: "Bun", title: "Bun" }] },
q0_custom: { type: "string", title: "Runtime (other)", description: "Type your own answer", maxLength: 20 },
q1: { type: "array", items: { anyOf: [{ const: "Fast", title: "Fast" }] } },
q1_custom: { type: "string", title: "q1 (other)", description: "Add your own answer" },
})
})
test("cancels forms from unsupported clients, unknown flows, and credential-looking fields", () => {
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
expect(ACPElicitation.requestedSchema(form(fields), { ...capable, formElicitation: false })).toBeUndefined()
@@ -198,91 +72,6 @@ describe("acp elicitation mapping", () => {
credentials.map(() => undefined),
)
})
test("cancels forms it cannot represent faithfully", () => {
const options = [{ value: "a", label: "A" }]
const unrepresentable: Array<ACPElicitation.AskedForm["fields"]> = [
[
{ key: "mode", type: "boolean" },
{ key: "detail", type: "string", when: [{ key: "mode", op: "eq", value: true }] },
],
[
{ key: "mode", type: "boolean" },
{ key: "detail", type: "string", hidden: true, default: "x", when: [{ key: "mode", op: "eq", value: true }] },
],
[{ key: "login", type: "external", url: "https://example.com/login" }],
[{ key: "server", type: "string", hidden: true, required: true }],
[{ key: "pick", type: "string", options, custom: true, required: true }],
[{ key: "pick", type: "multiselect", options, custom: true, maxItems: 1 }],
[{ key: "pick", type: "string", options, default: "b" }],
[{ key: "pick", type: "multiselect", options, default: ["a", "b"] }],
[
{ key: "pick", type: "string", options, custom: true },
{ key: "pick_custom", type: "string" },
],
]
expect(unrepresentable.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
unrepresentable.map(() => undefined),
)
expect(
ACPElicitation.requestedSchema(
form([
{ key: "name", type: "string" },
{ key: "pick", type: "string", options, default: "b", hidden: true },
]),
capable,
)?.properties,
).toEqual({ name: { type: "string" } })
})
test("maps an accepted response back to answers", () => {
const options = [{ value: "a", label: "A" }]
const fields: ACPElicitation.AskedForm["fields"] = [
{ key: "single", type: "string", options, custom: true },
{ key: "multi", type: "multiselect", options, custom: true },
{ key: "blank", type: "string", options, custom: true },
{ key: "count", type: "integer" },
{ key: "server", type: "string", hidden: true, default: "https://example.com" },
{ key: "region", type: "string", hidden: true },
]
expect(
ACPElicitation.answer(
form(fields),
accept({
single: "a",
single_custom: "typed",
multi: ["a"],
multi_custom: "extra",
blank: "a",
blank_custom: " ",
count: 3,
server: "https://other.example.com",
unknown: true,
}),
),
).toEqual({
single: "typed",
multi: ["a", "extra"],
blank: "a",
count: 3,
server: "https://example.com",
})
expect(ACPElicitation.answer(form(fields), accept({ multi_custom: "only" }))).toEqual({
multi: ["only"],
server: "https://example.com",
})
expect(ACPElicitation.answer(form(fields), { action: "accept" })).toEqual({ server: "https://example.com" })
})
test("has no answer unless the user accepted valid content", () => {
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
expect(ACPElicitation.answer(form(fields), { action: "decline" })).toBeUndefined()
expect(ACPElicitation.answer(form(fields), { action: "cancel" })).toBeUndefined()
expect(ACPElicitation.answer(form(fields), { action: "_custom" })).toBeUndefined()
expect(
ACPElicitation.answer(form(fields), { action: "accept", content: { name: { nested: true } } }),
).toBeUndefined()
})
})
describe("acp elicitation over the wire", () => {
@@ -338,190 +127,4 @@ describe("acp elicitation over the wire", () => {
expect(acp.server.repliedForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("cancels forms outside the allowed flows without asking", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_plugin",
sessionID,
title: "Plugin",
metadata: { kind: "plugin" },
fields: [{ key: "name", type: "string" }],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
})
test("cancels a question that looks like it asks for a credential with a message for the model", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_token",
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
fields: [
{
key: "q0",
title: "Setup",
description: "Paste your API key",
type: "string",
options: [{ value: "Later", label: "Later" }],
custom: true,
},
],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([
{ sessionID: acp.sessionId, formID: "frm_token", message: ACPElicitation.UnshownQuestionMessage },
])
})
test("cancels other forms it cannot show without a message", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_websearch",
sessionID,
title: "Web search provider",
metadata: { kind: "websearch.provider" },
fields: [{ key: "provider", type: "string", options: [{ value: "exa", label: "Exa" }] }],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_websearch" }])
})
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
questions(sessionID, "frm_pending"),
questions(sessionID, "frm_queued"),
],
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
elicitation: pendingUntilAborted,
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
await acp.until(() => acp.server.cancelledForms.length === 2, "form cancellation")
expect(acp.elicitations).toHaveLength(1)
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(["frm_pending", "frm_queued"])
expect(acp.server.repliedForms).toEqual([])
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
})
test("withdraws an elicitation for a form settled elsewhere and moves on to the next ask", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID, "frm_elsewhere")],
elicitation: (request, signal) =>
acp.elicitations.length === 1 ? pendingUntilAborted(request, signal) : accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID)],
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
acp.server.send(
ephemeralEvent("form.replied", { sessionID: acp.sessionId, id: "frm_elsewhere", answer: { q0: "Bun" } }),
questions(acp.sessionId, "frm_next"),
)
expect((await prompt).stopReason).toBe("end_turn")
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
expect(acp.elicitations).toHaveLength(2)
expect(acp.server.repliedForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_next", answer: { q0: "Node" } }])
expect(acp.server.cancelledForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("leaves the session alone when the user's answer arrives after the form settled", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID)],
elicitation: () => accept({ q0: "Bun" }),
fetch: (request) => {
if (!request.path.endsWith("/form/frm_question/reply")) return undefined
acp.server.send(succeeded(acp.sessionId))
return Response.json(
{ _tag: "FormAlreadySettledError", id: "frm_question", message: "Form already settled: frm_question" },
{ status: 409 },
)
},
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.server.requests.filter((request) => request.path.endsWith("/reply"))).toHaveLength(1)
expect(acp.server.cancelledForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("prefixes a foreground child form's tool call and message with the child", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
childCreated("ses_child", sessionID, "Review code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
questions("ses_child", "frm_child", "call_child"),
],
elicitation: () => accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toMatchObject([
{ sessionId: acp.sessionId, toolCallId: "ses_child:call_child", message: "Review code: Questions" },
])
expect(acp.server.repliedForms).toEqual([{ sessionID: "ses_child", formID: "frm_child", answer: { q0: "Node" } }])
})
test("omits the tool call when the child's tool calls only reach the client as child updates", async () => {
await using acp = await startSession({
capabilities: { elicitation: true, childSessionUpdates: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
childCreated("ses_child", sessionID, "Review code"),
questions("ses_child", "frm_child", "call_child"),
],
elicitation: () => accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toHaveLength(1)
expect(acp.elicitations[0]).toMatchObject({ sessionId: acp.sessionId, message: "Review code: Questions" })
expect(acp.elicitations[0]).not.toHaveProperty("toolCallId")
})
})
+29 -93
View File
@@ -1,55 +1,8 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import { Cause } from "effect"
import { ACPError } from "../../src/acp/error"
import { rpcError, startSession, startWire } from "./wire-fixture"
import { delivered, rpcError, startSession, textDelta, type Wire } from "./wire-fixture"
describe("acp errors", () => {
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" }),
new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" }),
]
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
-32602, -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())
expect(requestError).toBeInstanceOf(RequestError)
expect(requestError.code).toBe(-32000)
expect(requestError.message).toBe("Authentication required: provider authentication required")
expect(requestError.data).toEqual({})
})
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.fromUnknown(new Error("stack has sk-ant-secret and oauth refresh token")),
@@ -65,51 +18,34 @@ describe("acp errors", () => {
})
describe("acp error boundary over the wire", () => {
test("maps unexpected server failures to the generic internal error", async () => {
await using acp = await startWire({
fetch: (request) =>
request.method === "POST" && request.path === "/api/session" ? new Response(null, { status: 500 }) : undefined,
})
await acp.initialize()
test.each<[string, (acp: Wire) => void, string[]]>([
["ends", (acp) => acp.server.closeEvents(), []],
[
"drops its connection",
(acp) => acp.server.dropEvents(),
["ACP catalog event stream failed", "ACP selection event stream failed"],
],
])(
"reports an unavailable server when the event stream %s mid-turn and once the server stops",
async (_, lose, logs) => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), textDelta(sessionID, "msg_held", "working")],
})
const unavailable = {
code: -32603,
message: "Internal error: OpenCode server is unavailable",
data: { errorName: "ServerUnavailable" },
}
expect(await rpcError(acp.newSession())).toEqual({
code: -32603,
message: "Internal error: Internal service failure",
data: { errorName: "ClientError" },
})
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
{ message: ["ACP request failed"], cause: { _tag: "ClientError" } },
])
})
const prompt = acp.prompt(acp.sessionId, "hold")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
await acp.request("session/set_mode", { sessionId: acp.sessionId, modeId: "build" })
lose(acp)
test("maps rejected prompt submissions to invalid params with the server's message", async () => {
await using acp = await startSession({
fetch: (request) =>
request.method === "POST" && request.path.endsWith("/prompt")
? Response.json(
{ _tag: "InvalidRequestError", message: "File not readable: missing.png", field: "files" },
{ status: 400 },
)
: undefined,
})
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toEqual({
code: -32602,
message: "Invalid params: File not readable: missing.png",
data: { field: "files" },
})
expect(acp.logs).toEqual([])
})
test("reports an unavailable server once the server stops", async () => {
await using acp = await startSession()
await acp.server.stop()
expect(await rpcError(acp.request("session/list", {}))).toEqual({
code: -32603,
message: "Internal error: OpenCode server is unavailable",
data: { errorName: "ServerUnavailable" },
})
expect(acp.logs).toEqual([])
})
expect(await rpcError(prompt)).toEqual(unavailable)
await acp.server.stop()
expect(await rpcError(acp.request("session/list", {}))).toEqual(unavailable)
expect(acp.logs.map((log) => String(log.message)).toSorted()).toEqual(logs)
},
)
})
@@ -1,50 +0,0 @@
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize } from "./subprocess"
describe("acp initialize/auth subprocess", () => {
test("initialize responds with capabilities", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
}, 60_000)
test("auth negotiation is explicit and safe", async () => {
await using fixture = await createAcpFixture()
const secret = "subprocess-auth-secret"
const acp = fixture.spawn({ OPENCODE_AUTH_CONTENT: secret })
const initialized = await initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
expect(expectOk(await acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" }))).toEqual(
{},
)
const rejected = await acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expect(rejected.error?.code).toBe(-32602)
expect(JSON.stringify(rejected.error)).not.toContain(secret)
}, 60_000)
test("initialize without terminal-auth metadata keeps auth command implicit", async () => {
await using fixture = await createAcpFixture()
const initialized = expectOk(
await fixture.spawn().request<InitializeResponse>("initialize", { protocolVersion: 1 }),
)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
}, 60_000)
})
@@ -1,13 +1,5 @@
import type {
CloseSessionResponse,
DeleteSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { selectConfigOption } from "./select-options"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
import { createAcpFixture, initialize } from "./subprocess"
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
@@ -17,103 +9,6 @@ describe("acp lifecycle subprocess", () => {
expect(await acp.close()).toBe(0)
}, 60_000)
test("an incoming message over the size limit exits with an error", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const [code] = await Promise.all([
acp.exited,
// The agent stops reading partway through the line, so the write may fail.
acp.notify("opencode/oversized", { data: "a".repeat(32 * 1024 * 1024) }).catch(() => undefined),
])
await acp[Symbol.asyncDispose]()
expect(code).toBe(1)
expect(acp.stderr()).toContain("opencode acp: incoming message exceeded the 32 MiB limit\n")
}, 60_000)
test("close capability and close request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = await newSession(acp, fixture.home)
expect(
expectOk(await acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId })),
).toEqual({})
}, 60_000)
test("new session succeeds on the first request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
expect((await newSession(acp, fixture.home)).sessionId).toStartWith("ses_")
}, 60_000)
test("loadSession capability and load request return session config options", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
const session = await newSession(acp, fixture.home)
const loaded = expectOk(
await acp.request<LoadSessionResponse>("session/load", {
cwd: fixture.home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
const mismatched = await acp.request<LoadSessionResponse>("session/load", {
cwd: fixture.root,
sessionId: session.sessionId,
mcpServers: [],
})
expect(mismatched.error?.code).toBe(-32602)
}, 60_000)
test("list request includes a live ACP-created session", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}, 60_000)
test("delete capability and delete request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
const session = await newSession(acp, fixture.home)
expect(
expectOk(await acp.request<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId })),
).toEqual({})
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
}, 60_000)
test("resume request returns session config options", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const resumed = expectOk(
await acp.request<ResumeSessionResponse>("session/resume", {
cwd: fixture.home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}, 60_000)
// The private server is found with `pgrep`, which Windows lacks.
const testOutsideWindows = process.platform === "win32" ? test.skip : test
testOutsideWindows(
@@ -122,7 +17,6 @@ describe("acp lifecycle subprocess", () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
await newSession(acp, fixture.home)
const servers = Bun.spawnSync(["pgrep", "-P", String(acp.pid)])
.stdout.toString()
.split("\n")
+177 -608
View File
@@ -1,24 +1,20 @@
import { describe, expect, test } from "bun:test"
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import type { AnyRequest, CreateElicitationResponse, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
import { createTwoFilesPatch } from "diff"
import { Cause } from "effect"
import fs from "node:fs/promises"
import path from "node:path"
import { tmpdir } from "../fixture/tmpdir"
import {
childCreated,
delivered,
durableEvent,
ephemeralEvent,
interrupted,
permissionAsked,
startSession,
startWire,
stepEnded,
succeeded,
textDelta,
toolCalled,
toolStarted,
toolSucceeded,
turn,
type Wire,
} from "./wire-fixture"
@@ -27,6 +23,15 @@ const allowOnce = () => ({ outcome: { outcome: "selected", optionId: "once" } })
describe("acp permissions over the wire", () => {
test("forwards allow-once and allow-always selections to the server", async () => {
const selections: Record<string, () => RequestPermissionResponse> = {
call_once: allowOnce,
call_always: () => ({ outcome: { outcome: "selected", optionId: "always" } }),
perm_selected_reject: () => ({ outcome: { outcome: "selected", optionId: "reject" } }),
perm_cancelled: () => ({ outcome: { outcome: "cancelled" } }),
perm_failed: () => {
throw new Error("client permission UI failed")
},
}
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
@@ -46,13 +51,14 @@ describe("acp permissions over the wire", () => {
metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
}),
permissionAsked(sessionID, "perm_selected_reject"),
permissionAsked(sessionID, "perm_cancelled"),
permissionAsked(sessionID, "perm_failed"),
),
permission: (request) => ({
outcome: { outcome: "selected", optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always" },
}),
permission: (request) => selections[request.toolCall.toolCallId](),
})
await acp.prompt(acp.sessionId, "hello")
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(acp.permissions[0]).toMatchObject({
sessionId: acp.sessionId,
@@ -84,485 +90,170 @@ describe("acp permissions over the wire", () => {
expect(decisions(acp)).toEqual([
["perm_once", "once"],
["perm_always", "always"],
])
})
test("preserves external directory permission context", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_external", {
action: "external_directory",
metadata: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
}),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "/tmp/outside",
locations: [{ path: "/tmp/outside/a.ts" }],
})
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
})
test("locates path resources without glob patterns when the ask has no tool locations", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_external", {
action: "external_directory",
resources: ["/tmp/outside/*", "/tmp/other/*", "/tmp/outside/*"],
metadata: {},
}),
permissionAsked(sessionID, "perm_read", {
action: "read",
resources: ["src/[slug].ts", "src/{a,b}.ts", "**/*.ts", "src/?.ts"],
}),
permissionAsked(sessionID, "perm_search", { action: "websearch", resources: ["acp spec"] }),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall.locations)).toEqual([
[{ path: "/tmp/outside" }, { path: "/tmp/other" }],
[{ path: path.resolve("/workspace", "src/[slug].ts") }, { path: path.resolve("/workspace", "src/{a,b}.ts") }],
[],
])
})
test("asks under the permission ID when ACP does not track the asking tool call", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_untracked", {
source: { type: "tool", messageID: "msg_tools", id: "call_untracked" },
}),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_untracked"])
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
})
test("asks about a tracked tool call with its own input", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_read", "read"),
toolCalled(sessionID, "call_read", { path: "/workspace/file.ts" }),
permissionAsked(sessionID, "perm_read", {
action: "read",
metadata: { files: ["file.ts"] },
source: { type: "tool", messageID: "msg_read", id: "call_read" },
}),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
const running = acp.updates.flatMap((item) =>
item.update.sessionUpdate === "tool_call_update" && item.update.status === "in_progress"
? [item.update.rawInput]
: [],
)
expect(running).toEqual([{ path: "/workspace/file.ts" }])
expect(acp.permissions[0]?.toolCall).toMatchObject({ toolCallId: "call_read", rawInput: running[0] })
})
test("routes foreground child permissions through the parent ACP session", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Review code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
toolStarted("ses_child", "call_child", "read"),
toolCalled("ses_child", "call_child", { path: "/workspace/child.ts" }),
permissionAsked("ses_child", "perm_child", {
action: "read",
source: { type: "tool", messageID: "msg_child", id: "call_child" },
}),
succeeded("ses_child"),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
const childMeta = {
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Review code" },
}
expect(acp.permissions).toHaveLength(1)
expect(acp.permissions[0]).toMatchObject({
sessionId: acp.sessionId,
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts", _meta: childMeta },
})
expect(
acp.updates.flatMap((item) => (item.update.sessionUpdate === "tool_call" ? [item.update._meta] : [])),
).toEqual([childMeta])
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
})
test("asks for a background child's permission after the parent turn ends without the child capability", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => turn(sessionID, id, childCreated("ses_background", sessionID, "Research")),
permission: allowOnce,
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
acp.server.send(
durableEvent("session.execution.started", { sessionID: "ses_background" }),
permissionAsked("ses_background", "perm_background", {
action: "read",
metadata: { path: "/workspace/notes.md" },
}),
)
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
const childMeta = {
"opencode/child-session": { id: "ses_background", parentID: acp.sessionId, depth: 1, title: "Research" },
}
expect(acp.permissions).toMatchObject([
{
sessionId: acp.sessionId,
toolCall: {
toolCallId: "ses_background:perm_background",
title: "Research: /workspace/notes.md",
_meta: childMeta,
},
},
])
expect(acp.server.replies).toEqual([
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
])
})
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_selected_reject"),
permissionAsked(sessionID, "perm_cancelled"),
permissionAsked(sessionID, "perm_failed"),
),
permission(request) {
if (request.toolCall.toolCallId === "perm_selected_reject") {
return { outcome: { outcome: "selected", optionId: "reject" } }
}
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
throw new Error("client permission UI failed")
},
})
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(decisions(acp)).toEqual([
["perm_selected_reject", "reject"],
["perm_cancelled", "reject"],
["perm_failed", "reject"],
])
})
test("logs a failed server reply and still answers later asks", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, permissionAsked(sessionID, "perm_failed"), permissionAsked(sessionID, "perm_next")),
fetch: (request) =>
request.path.endsWith("/permission/perm_failed/reply") ? new Response(null, { status: 500 }) : undefined,
permission: allowOnce,
})
test.each<[string, string, string, (sessionID: string, id: string) => OpenCodeEventEncoded]>([
["session/request_permission", "perm", "reject", (sessionID, id) => permissionAsked(sessionID, id)],
["elicitation/create", "frm", "cancelled", (sessionID, id) => question(sessionID, id)],
])(
"cancelling the turn cancels its pending %s and settles queued asks without sending them",
async (method, prefix, outcome, ask) => {
const pendingUntilAborted =
<Response>(cancelled: Response) =>
(_request: unknown, signal: AbortSignal) =>
new Promise<Response>((resolve) => signal.addEventListener("abort", () => resolve(cancelled), { once: true }))
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ask(sessionID, `${prefix}_pending`),
ask(sessionID, `${prefix}_queued`),
],
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
permission: pendingUntilAborted<RequestPermissionResponse>({ outcome: { outcome: "cancelled" } }),
elicitation: pendingUntilAborted<CreateElicitationResponse>({ action: "cancel" }),
})
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_failed", "perm_next"])
expect(decisions(acp)).toEqual([["perm_next", "once"]])
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
{ message: ["ACP permission reply failed"], cause: { _tag: "ClientError" } },
])
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.permissions.length + acp.elicitations.length === 1, "the pending ask")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
test("serializes permission requests and replies within one session", async () => {
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, permissionAsked(sessionID, "perm_1"), permissionAsked(sessionID, "perm_2")),
permission: (request) =>
request.toolCall.toolCallId === "perm_1"
? releaseFirst.promise
: { outcome: { outcome: "selected", optionId: "always" } },
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.permissions.length === 1, "first permission")
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
expect(acp.server.replies).toEqual([])
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
await prompt
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
expect(decisions(acp)).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
})
test("keeps streaming other children while one child's permission is pending", async () => {
const release = Promise.withResolvers<RequestPermissionResponse>()
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_a", sessionID, "A"),
childCreated("ses_b", sessionID, "B"),
permissionAsked("ses_a", "perm_a"),
textDelta("ses_b", "msg_b", "still streaming"),
succeeded("ses_b"),
succeeded("ses_a"),
),
permission: () => release.promise,
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk", "child B's chunk")
await acp.until(() => acp.permissions.length === 1, "child A's permission")
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["ses_a:perm_a"])
expect(acp.server.replies).toEqual([])
release.resolve({ outcome: { outcome: "selected", optionId: "once" } })
expect((await prompt).stopReason).toBe("end_turn")
expect(decisions(acp)).toEqual([["perm_a", "once"]])
})
test("does not let one session's blocked permission stall another session", async () => {
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
await using acp = await startWire({ onPrompt: () => undefined, permission: () => releaseBlocked.promise })
await acp.initialize()
const blockedSession = await acp.newSession()
const freeSession = await acp.newSession()
const blocked = acp.prompt(blockedSession.sessionId, "hello")
const free = acp.prompt(freeSession.sessionId, "hello")
const [blockedPrompt, freePrompt] = await acp.until(
() => acp.server.prompts.length === 2 && acp.server.prompts,
"both prompt submissions",
)
acp.server.send(
delivered(blockedPrompt.sessionID, blockedPrompt.id),
delivered(freePrompt.sessionID, freePrompt.id),
permissionAsked(blockedPrompt.sessionID, "perm_blocked"),
textDelta(freePrompt.sessionID, "msg_free", "session B continued"),
stepEnded(freePrompt.sessionID, "msg_free"),
succeeded(freePrompt.sessionID),
succeeded(blockedPrompt.sessionID),
)
await acp.until(() => acp.permissions.length === 1, "blocked permission")
expect(await free).toMatchObject({ stopReason: "end_turn" })
expect(acp.updates).toContainEqual({
sessionId: freePrompt.sessionID,
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_free",
content: { type: "text", text: "session B continued" },
},
})
expect(acp.server.replies).toEqual([])
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
expect(await blocked).toMatchObject({ stopReason: "end_turn" })
expect(decisions(acp)).toEqual([["perm_blocked", "once"]])
})
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), permissionAsked(sessionID, "perm_cancel")],
onPermissionReply: ({ sessionID }) => [interrupted(sessionID)],
permission: (_request, signal) =>
new Promise((resolve) => {
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
}),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.permissions.length === 1, "permission request")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
expect(decisions(acp)).toEqual([["perm_cancel", "reject"]])
const asked = acp.received.find(
(message): message is AnyRequest =>
"method" in message && "id" in message && message.method === "session/request_permission",
)
expect(acp.received).toContainEqual({
jsonrpc: "2.0",
method: "$/cancel_request",
params: { requestId: asked?.id },
})
})
test("rejects asks queued behind a cancelled one without sending them to the client", async () => {
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
const settled = () => [
...acp.server.replies.map((reply) => [reply.requestID, reply.decision]),
...acp.server.cancelledForms.map((form) => [form.formID, "cancelled"]),
]
await acp.until(() => settled().length === 2, "both asks settled")
expect(settled()).toEqual([
[`${prefix}_pending`, outcome],
[`${prefix}_queued`, outcome],
])
expect(acp.server.repliedForms).toEqual([])
const asked = acp.received.filter(
(message): message is AnyRequest => "method" in message && "id" in message && message.method === method,
)
expect(asked).toHaveLength(1)
expect(acp.received).toContainEqual({
jsonrpc: "2.0",
method: "$/cancel_request",
params: { requestId: asked[0]?.id },
})
},
)
test("withdraws permission requests settled elsewhere and still asks the remaining one", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
permissionAsked(sessionID, "perm_pending"),
permissionAsked(sessionID, "perm_elsewhere"),
permissionAsked(sessionID, "perm_queued"),
permissionAsked(sessionID, "perm_remaining"),
],
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
fetch: (request) => {
if (request.path.endsWith("/permission/perm_remaining/reply")) acp.server.send(succeeded(acp.sessionId))
return undefined
},
permission: (_request, signal) =>
new Promise((resolve) => {
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
}),
acp.permissions.length === 1
? new Promise((resolve) => {
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
})
: allowOnce(),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.permissions.length === 1, "permission request")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
acp.server.send(
ephemeralEvent("permission.replied", { sessionID: acp.sessionId, requestID: "perm_queued", reply: "always" }),
ephemeralEvent("permission.replied", { sessionID: acp.sessionId, requestID: "perm_elsewhere", reply: "always" }),
)
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_pending"])
expect(decisions(acp)).toEqual([
["perm_pending", "reject"],
["perm_queued", "reject"],
])
expect(await prompt).toMatchObject({ stopReason: "end_turn" })
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_elsewhere", "perm_remaining"])
expect(decisions(acp)).toEqual([["perm_remaining", "once"]])
const cancels = acp.received.filter((message) => "method" in message && message.method === "$/cancel_request")
expect(cancels).toHaveLength(1)
expect(acp.logs).toEqual([])
})
})
describe("acp edit previews over the wire", () => {
test("previews core's edit diff during approval", async () => {
await using dir = await tmpdir()
const file = path.join(dir.path, "file.ts")
await fs.writeFile(file, "one\r\ntwo\r\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" }),
permissionAsked(sessionID, "perm_edit", {
action: "edit",
resources: ["file.ts"],
metadata: { files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
),
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "file.ts",
kind: "edit",
locations: [{ path: file }],
content: [{ type: "diff", path: file, oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
})
})
test("previews each file in a patch", async () => {
await using dir = await tmpdir()
await Promise.all([
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
fs.writeFile(path.join(dir.path, "second.ts"), "alpha\n"),
])
const patchText = [
"*** Begin Patch",
"*** Update File: first.ts",
"@@",
"-one",
"+two",
"*** Update File: second.ts",
"@@",
"-alpha",
"+beta",
"*** End Patch",
].join("\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
permissionAsked(sessionID, "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
],
onPermissionReply: async ({ sessionID }) => {
await Promise.all([
fs.writeFile(path.join(dir.path, "first.ts"), "two\n"),
fs.writeFile(path.join(dir.path, "second.ts"), "beta\n"),
])
return [
toolSucceeded(sessionID, "call_patch", { files: [{ file: "first.ts" }, { file: "second.ts" }] }, "patched"),
succeeded(sessionID),
]
},
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "2 files",
kind: "edit",
locations: [{ path: path.join(dir.path, "first.ts") }, { path: path.join(dir.path, "second.ts") }],
content: [
{ type: "diff", path: path.join(dir.path, "first.ts"), oldText: "one\n", newText: "two\n" },
{ type: "diff", path: path.join(dir.path, "second.ts"), oldText: "alpha\n", newText: "beta\n" },
],
})
})
test("previews added files as new and deleted files as empty", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "gone.ts"), "gone\n")
const patchText = ["*** Begin Patch", "*** Add File: added.ts", "+one", "*** End Patch"].join("\n")
await Promise.all(
Object.entries({
"file.ts": "one\r\ntwo\r\n",
"first.ts": "one\n",
"second.ts": "alpha\n",
"gone.ts": "gone\n",
"stale.ts": "changed\n",
"unpatched.ts": "changed\n",
}).map(([name, content]) => fs.writeFile(path.join(dir.path, name), content)),
)
await fs.mkdir(path.join(dir.path, "folder"))
const file = (name: string) => path.join(dir.path, name)
const patch = (...lines: string[]) => ["*** Begin Patch", ...lines, "*** End Patch"].join("\n")
const edit = (sessionID: string, call: string, tool: string, input: Record<string, unknown>, metadata = {}) => [
toolStarted(sessionID, call, tool),
toolCalled(sessionID, call, input),
permissionAsked(sessionID, `perm_${call}`, {
action: "edit",
metadata,
source: { type: "tool", messageID: "msg_edit", id: call },
}),
]
await using acp = await startWire({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_write", "write"),
toolCalled(sessionID, "call_write", { path: "written.ts", content: "two\n" }),
permissionAsked(sessionID, "perm_write", {
action: "edit",
metadata: { files: [fileDiff("written.ts", "", "two\n", "added")] },
source: { type: "tool", messageID: "msg_write", id: "call_write" },
}),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
permissionAsked(sessionID, "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
...edit(
sessionID,
"call_edit",
"edit",
{ path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" },
{ files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
),
...edit(sessionID, "call_patch", "patch", {
patchText: patch(
"*** Update File: first.ts",
"@@",
"-one",
"+two",
"*** Update File: second.ts",
"@@",
"-alpha",
"+beta",
),
}),
...edit(
sessionID,
"call_write",
"write",
{ path: "written.ts", content: "two\n" },
{ files: [fileDiff("written.ts", "", "two\n", "added")] },
),
...edit(sessionID, "call_add", "patch", { patchText: patch("*** Add File: added.ts", "+one") }),
permissionAsked(sessionID, "perm_delete", {
action: "edit",
metadata: { files: [fileDiff("gone.ts", "gone\n", "", "deleted")] },
}),
permissionAsked(sessionID, "perm_stale", {
action: "edit",
resources: ["stale.ts"],
metadata: { files: [fileDiff("stale.ts", "one\n", "two\n")] },
}),
permissionAsked(sessionID, "perm_folder", {
action: "edit",
resources: ["folder"],
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
}),
...edit(sessionID, "call_unpatched", "patch", {
patchText: patch("*** Update File: unpatched.ts", "@@", "-one", "+two"),
}),
),
permission: allowOnce,
})
@@ -572,165 +263,43 @@ describe("acp edit previews over the wire", () => {
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([
[{ type: "diff", path: path.join(dir.path, "written.ts"), oldText: null, newText: "two\n" }],
[{ type: "diff", path: path.join(dir.path, "added.ts"), oldText: null, newText: "one\n" }],
[{ type: "diff", path: path.join(dir.path, "gone.ts"), oldText: "gone\n", newText: "" }],
])
})
test("asks without previews when core's diff no longer applies or the file cannot be read", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "file.ts"), "changed\n")
await fs.mkdir(path.join(dir.path, "folder"))
await using acp = await startWire({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_stale", {
action: "edit",
resources: ["file.ts"],
metadata: { files: [fileDiff("file.ts", "one\n", "two\n")] },
}),
permissionAsked(sessionID, "perm_folder", {
action: "edit",
resources: ["folder"],
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
}),
),
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall)).toMatchObject([
{ kind: "edit", locations: [{ path: path.join(dir.path, "file.ts") }] },
{ kind: "edit", locations: [{ path: path.join(dir.path, "folder") }] },
])
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([undefined, undefined])
expect(decisions(acp)).toEqual([
["perm_stale", "once"],
["perm_folder", "once"],
])
})
test("asks without previews when a patch does not apply to the current file", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
const patchText = ["*** Begin Patch", "*** Update File: first.ts", "@@", "-one", "+two", "*** End Patch"].join("\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
permissionAsked(sessionID, "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
[{ type: "diff", path: file("file.ts"), oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
[
{ type: "diff", path: file("first.ts"), oldText: "one\n", newText: "two\n" },
{ type: "diff", path: file("second.ts"), oldText: "alpha\n", newText: "beta\n" },
],
onPermissionReply: ({ sessionID }) => [succeeded(sessionID)],
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
kind: "edit",
locations: [{ path: path.join(dir.path, "first.ts") }],
})
expect(acp.permissions[0]?.toolCall.content).toBeUndefined()
expect(decisions(acp)).toEqual([["perm_patch", "once"]])
})
test("reports the same absolute locations for a moved file in the permission and tool updates", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "old.ts"), "one\n")
const patchText = [
"*** Begin Patch",
"*** Update File: old.ts",
"*** Move to: new.ts",
"@@",
"-one",
"+two",
"*** End Patch",
].join("\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_move", "patch"),
toolCalled(sessionID, "call_move", { patchText }),
permissionAsked(sessionID, "perm_move", {
action: "edit",
source: { type: "tool", messageID: "msg_move", id: "call_move" },
}),
],
onPermissionReply: ({ sessionID }) => [
toolSucceeded(sessionID, "call_move", {}, "patched"),
succeeded(sessionID),
],
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
const locations = [{ path: path.join(dir.path, "old.ts") }, { path: path.join(dir.path, "new.ts") }]
expect(acp.permissions[0]?.toolCall).toMatchObject({
locations,
content: [{ type: "diff", path: path.join(dir.path, "new.ts"), oldText: "one\n", newText: "two\n" }],
})
expect(
acp.updates.flatMap((item) =>
item.update.sessionUpdate === "tool_call_update" && item.update.toolCallId === "call_move"
? [[item.update.status, item.update.locations]]
: [],
),
).toEqual([
["in_progress", locations],
["completed", locations],
[{ type: "diff", path: file("written.ts"), oldText: null, newText: "two\n" }],
[{ type: "diff", path: file("added.ts"), oldText: null, newText: "one\n" }],
[{ type: "diff", path: file("gone.ts"), oldText: "gone\n", newText: "" }],
undefined,
undefined,
undefined,
])
})
test("does not echo completed edits to a client that advertises writeTextFile", async () => {
await using dir = await tmpdir()
const file = path.join(dir.path, "file.ts")
await fs.writeFile(file, "after")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { filePath: file, oldString: "before", newString: "after" }),
toolSucceeded(sessionID, "call_edit", { files: [{ file }] }, "edited"),
),
})
await acp.initialize({ writeTextFile: true })
const session = await acp.newSession(dir.path)
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(acp.writes).toEqual([])
expect(
acp.updates.flatMap((item) =>
item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed" ? [item.update] : [],
),
).toMatchObject([
{
toolCallId: "call_edit",
content: [
{ type: "content", content: { type: "text", text: "edited" } },
{ type: "diff", path: file, oldText: "before", newText: "after" },
],
},
expect(acp.permissions.slice(0, 2).map((request) => request.toolCall)).toMatchObject([
{ title: "file.ts", kind: "edit", locations: [{ path: file("file.ts") }] },
{ title: "2 files", kind: "edit", locations: [{ path: file("first.ts") }, { path: file("second.ts") }] },
])
expect(acp.permissions.slice(5).map((request) => request.toolCall.locations)).toEqual([
[{ path: file("stale.ts") }],
[{ path: file("folder") }],
[{ path: file("unpatched.ts") }],
])
expect(decisions(acp)).toHaveLength(8)
})
})
function question(sessionID: string, id: string) {
return ephemeralEvent("form.created", {
form: {
id,
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", title: "Runtime", type: "string", options: [{ value: "Bun", label: "Bun" }] }],
},
})
}
function decisions(acp: Wire) {
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
}
+231 -476
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { StopReason } from "@agentclientprotocol/sdk"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
import { Schema } from "effect"
import { mkdir } from "node:fs/promises"
@@ -7,21 +7,26 @@ import path from "node:path"
import { pathToFileURL } from "node:url"
import { tmpdir } from "../fixture/tmpdir"
import {
childCreated,
delivered,
durableEvent,
ephemeralEvent,
failed,
interrupted,
makeSession,
reasoningDelta,
reviewCommand,
rpcError,
startSession,
startWire,
stepEnded,
succeeded,
textDelta,
tokens,
toolCalled,
toolFailed,
toolProgress,
toolStarted,
toolSucceeded,
turn,
type Wire,
type WireOptions,
@@ -36,156 +41,8 @@ const held = {
describe("acp prompt turns over the wire", () => {
test("streams an admitted turn and resolves with usage after its terminal event", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, textDelta(sessionID, "msg_assistant", "hello"), stepEnded(sessionID, "msg_assistant")),
})
const response = await acp.prompt(acp.sessionId, "hi")
expect(response).toEqual({
stopReason: "end_turn",
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
_meta: {},
})
expect(acp.updates.filter((item) => item.update.sessionUpdate === "agent_message_chunk")).toEqual([
{
sessionId: acp.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hello" },
},
},
])
expect(acp.server.submissions).toEqual([
{
kind: "prompt",
sessionID: acp.sessionId,
id: expect.stringMatching(/^msg_/),
text: "hi",
files: [],
delivery: "steer",
},
])
})
test("routes slash commands and compact through their session endpoints", async () => {
await using acp = await startSession()
const command = await acp.prompt(acp.sessionId, "/review now")
const compact = await acp.prompt(acp.sessionId, "/compact")
expect([command.stopReason, compact.stopReason]).toEqual(["end_turn", "end_turn"])
expect(acp.server.submissions).toEqual([
{ kind: "command", sessionID: acp.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
{ kind: "compact", sessionID: acp.sessionId, id: expect.stringMatching(/^msg_/) },
])
})
test("submits assistant-only context as synthetic input before the visible prompt", async () => {
await using dir = await tmpdir()
const readme = pathToFileURL(path.join(dir.path, "README.md")).href
await Bun.write(path.join(dir.path, "README.md"), "# readme\n")
await using acp = await startSession()
await acp.prompt(acp.sessionId, [
{ type: "text", text: "visible" },
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
{ type: "resource_link", uri: readme, name: "README.md", mimeType: "text/markdown" },
])
expect(acp.server.submissions).toEqual([
{
kind: "synthetic",
sessionID: acp.sessionId,
text: "hidden context",
description: "ACP embedded context",
delivery: "steer",
resume: false,
},
expect.objectContaining({
kind: "prompt",
text: "visible",
files: [{ uri: readme, name: "README.md" }],
}),
])
})
test("attaches readable file links and references unreadable ones in place", async () => {
await using dir = await tmpdir()
const file = pathToFileURL(path.join(dir.path, "notes.md")).href
const folder = pathToFileURL(path.join(dir.path, "src")).href
const missing = pathToFileURL(path.join(dir.path, "missing.md")).href
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
await mkdir(path.join(dir.path, "src"))
await using acp = await startSession()
const response = await acp.prompt(acp.sessionId, [
{ type: "text", text: "compare" },
{ type: "resource_link", uri: file, name: "notes.md" },
{ type: "resource_link", uri: missing, name: "missing.md" },
{ type: "resource_link", uri: folder, name: "src" },
{ type: "text", text: "please" },
])
expect(response.stopReason).toBe("end_turn")
expect(acp.server.submissions).toEqual([
expect.objectContaining({
kind: "prompt",
text: `compare\n[missing.md](${missing})\nplease`,
files: [
{ uri: file, name: "notes.md" },
{ uri: folder, name: "src" },
],
}),
])
})
test("attaches local image links and references remote or unreadable ones in place", async () => {
await using dir = await tmpdir()
const local = pathToFileURL(path.join(dir.path, "local.png")).href
const missing = pathToFileURL(path.join(dir.path, "missing.png")).href
await Bun.write(path.join(dir.path, "local.png"), "png")
await using acp = await startSession()
const response = await acp.prompt(acp.sessionId, [
{ type: "text", text: "compare" },
{ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/remote.png" },
{ type: "image", data: "", mimeType: "image/png", uri: local },
{ type: "image", data: "", mimeType: "image/png", uri: missing },
])
expect(response.stopReason).toBe("end_turn")
expect(acp.server.submissions).toEqual([
expect.objectContaining({
kind: "prompt",
text: `compare\n[remote.png](https://example.com/remote.png)\n[missing.png](${missing})`,
files: [{ uri: local, name: "local.png" }],
}),
])
})
test("rejects an image with neither data nor uri as invalid params", async () => {
await using acp = await startSession()
expect(
await rpcError(
acp.prompt(acp.sessionId, [
{ type: "text", text: "look" },
{ type: "image", data: "", mimeType: "image/png" },
]),
),
).toMatchObject({ code: -32602, message: expect.stringContaining("image content has no data or uri") })
expect(acp.server.submissions).toEqual([])
})
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
const assistantTokens = { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } }
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, stepEnded(sessionID, "msg_assistant", { tokens: assistantTokens })),
})
const releaseAdmission = Promise.withResolvers<void>()
await using acp = await startSession({ onPrompt: () => releaseAdmission.promise })
await acp.request("session/set_config_option", {
sessionId: acp.sessionId,
configId: "model",
@@ -193,9 +50,38 @@ describe("acp prompt turns over the wire", () => {
})
acp.server.sessions.set(acp.sessionId, makeSession(acp.sessionId, { cost: 3.5 }))
const response = await acp.prompt(acp.sessionId, "hello")
const prompt = acp.prompt(acp.sessionId, "hi")
const settled = { value: false }
void prompt.finally(() => {
settled.value = true
})
const submitted = await acp.until(() => acp.server.prompts[0], "prompt submission")
const sessionID = acp.sessionId
acp.server.send(
...turn(
sessionID,
submitted.id,
reasoningDelta(sessionID, "msg_assistant", "think-1"),
reasoningDelta(sessionID, "msg_assistant", " continued"),
textDelta(sessionID, "msg_assistant", "hello", 1),
reasoningDelta(sessionID, "msg_assistant", "think-2", 1),
toolStarted(sessionID, "call_ok", "shell"),
toolCalled(sessionID, "call_ok", { command: "printf done", workdir: "sub" }),
toolProgress(sessionID, "call_ok", { phase: 1 }),
toolSucceeded(sessionID, "call_ok", { exit: 0 }, "done"),
toolStarted(sessionID, "call_fail", "read"),
toolCalled(sessionID, "call_fail", { path: "/workspace/missing.ts" }),
toolFailed(sessionID, "call_fail", { error: { type: "tool.error", message: "not found" } }),
stepEnded(sessionID, "msg_assistant", {
tokens: { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } },
}),
),
)
await acp.until(() => turnUpdates(acp.updates).length === 11, "streamed updates")
expect(settled.value).toBe(false)
releaseAdmission.resolve()
expect(response).toEqual({
expect(await prompt).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 100,
@@ -207,144 +93,169 @@ describe("acp prompt turns over the wire", () => {
},
_meta: {},
})
expect(acp.server.submissions).toEqual([
{ kind: "prompt", sessionID, id: expect.stringMatching(/^msg_/), text: "hi", files: [], delivery: "steer" },
])
expect(
turnUpdates(acp.updates).map((item) => [
item.update.sessionUpdate,
"toolCallId" in item.update
? item.update.toolCallId
: "messageId" in item.update
? item.update.messageId
: undefined,
"status" in item.update ? item.update.status : undefined,
]),
).toEqual([
["agent_thought_chunk", "msg_assistant:reasoning:0", undefined],
["agent_thought_chunk", "msg_assistant:reasoning:0", undefined],
["agent_message_chunk", "msg_assistant", undefined],
["agent_thought_chunk", "msg_assistant:reasoning:1", undefined],
["tool_call", "call_ok", "pending"],
["tool_call_update", "call_ok", "in_progress"],
["tool_call_update", "call_ok", "in_progress"],
["tool_call_update", "call_ok", "completed"],
["tool_call", "call_fail", "pending"],
["tool_call_update", "call_fail", "in_progress"],
["tool_call_update", "call_fail", "failed"],
])
expect(turnUpdates(acp.updates)[2]?.update).toEqual({
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hello" },
})
expect(turnUpdates(acp.updates)[5]?.update).toMatchObject({
title: "printf done",
kind: "execute",
locations: [{ path: path.resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
})
expect(turnUpdates(acp.updates)[7]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { metadata: { exit: 0 } },
})
expect(turnUpdates(acp.updates)[10]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "not found" } }],
rawOutput: { error: "not found" },
})
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
sessionId: acp.sessionId,
sessionId: sessionID,
update: { sessionUpdate: "usage_update", used: 171, size: 200_000, cost: { amount: 3.5, currency: "USD" } },
})
})
test("completes the prompt when reads after admission fail", async () => {
const reads = { failing: false }
await using acp = await startSession({
fetch: (request) => (reads.failing && request.method === "GET" ? new Response(null, { status: 500 }) : undefined),
onPrompt: ({ sessionID, id }) => {
reads.failing = true
return turn(sessionID, id, stepEnded(sessionID, "msg_assistant"))
},
})
test("routes slash commands and compact through their session endpoints", async () => {
await using acp = await startWire()
acp.server.catalog.commands = [reviewCommand, { name: "compact", description: "Server compact" }]
await acp.initialize()
const session = await acp.newSession()
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
})
const command = await acp.prompt(session.sessionId, "/review now")
const compact = await acp.prompt(session.sessionId, "/compact")
test.each<{ name: string; stopReason: StopReason; events: (sessionID: string) => OpenCodeEventEncoded[] }>([
{ name: "a normal stop", stopReason: "end_turn", events: (id) => [stepEnded(id, "msg_1"), succeeded(id)] },
{
name: "a length-limited step",
stopReason: "max_tokens",
events: (id) => [stepEnded(id, "msg_1", { finish: "length" }), succeeded(id)],
},
{
name: "a content-filtered step",
stopReason: "refusal",
events: (id) => [stepEnded(id, "msg_1", { finish: "content-filter" }), succeeded(id)],
},
{
name: "a content-filter failure",
stopReason: "refusal",
events: (id) => [failed(id, { type: "provider.content-filter", message: "blocked" })],
},
{
name: "a server-side interruption",
stopReason: "cancelled",
events: (id) => [durableEvent("session.execution.interrupted", { sessionID: id, reason: "shutdown" })],
},
])("maps $name to $stopReason", async (input) => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...input.events(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe(input.stopReason)
})
test("maps provider auth failures to auth required", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
failed(sessionID, { type: "provider.auth", message: "missing key" }),
],
})
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
})
test("maps an assistant step auth error to auth required", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
textDelta(sessionID, "msg_auth", "partial"),
durableEvent("session.step.failed", {
sessionID,
assistantMessageID: "msg_auth",
error: { type: "provider.auth", message: "expired" },
}),
),
})
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
})
test("surfaces other execution failures as internal errors with the failure message", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
failed(sessionID, { type: "provider.rate-limit", message: "slow down" }),
],
})
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({
code: -32603,
message: expect.stringContaining("slow down"),
})
})
test("reports provider retries while pending and clears them when the next step starts", async () => {
const at = Date.UTC(2026, 0, 1)
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
retryScheduled(sessionID, 2, at),
durableEvent("session.step.started", {
sessionID,
assistantMessageID: "msg_retry",
agent: "build",
model: { providerID: "test", id: "test-model" },
started: at,
}),
stepEnded(sessionID, "msg_retry"),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(acp.updates.filter((item) => item.update.sessionUpdate === "session_info_update")).toEqual([
{
sessionId: acp.sessionId,
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": retryMeta(2, at) } },
},
{
sessionId: acp.sessionId,
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": null } },
},
expect([command.stopReason, compact.stopReason]).toEqual(["end_turn", "end_turn"])
expect(acp.server.submissions).toEqual([
{ kind: "command", sessionID: session.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
{ kind: "compact", sessionID: session.sessionId, id: expect.stringMatching(/^msg_/) },
])
expect(response._meta).toEqual({})
expect(
(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).update,
).toEqual({
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "compact", description: "Compact the session" },
],
})
})
test("reports the pending retry on a turn cancelled during backoff", async () => {
const at = Date.UTC(2026, 0, 1)
test("attaches readable file links and references unreadable ones in place", async () => {
await using dir = await tmpdir()
const file = pathToFileURL(path.join(dir.path, "notes.md")).href
const folder = pathToFileURL(path.join(dir.path, "src")).href
const missing = pathToFileURL(path.join(dir.path, "missing.md")).href
const image = pathToFileURL(path.join(dir.path, "local.png")).href
const missingImage = pathToFileURL(path.join(dir.path, "missing.png")).href
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
await Bun.write(path.join(dir.path, "local.png"), "png")
await mkdir(path.join(dir.path, "src"))
await using acp = await startSession()
const response = await acp.prompt(acp.sessionId, [
{ type: "text", text: "compare" },
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
{ type: "resource_link", uri: file, name: "notes.md" },
{ type: "resource_link", uri: missing, name: "missing.md" },
{ type: "resource_link", uri: folder, name: "src" },
{ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/remote.png" },
{ type: "image", data: "", mimeType: "image/png", uri: image },
{ type: "image", data: "", mimeType: "image/png", uri: missingImage },
{ type: "text", text: "please" },
])
expect(response.stopReason).toBe("end_turn")
expect(acp.server.submissions).toEqual([
{
kind: "synthetic",
sessionID: acp.sessionId,
text: "hidden context",
description: "ACP embedded context",
delivery: "steer",
resume: false,
},
expect.objectContaining({
kind: "prompt",
text: `compare\n[missing.md](${missing})\n[remote.png](https://example.com/remote.png)\n[missing.png](${missingImage})\nplease`,
files: [
{ uri: file, name: "notes.md" },
{ uri: folder, name: "src" },
{ uri: image, name: "local.png" },
],
}),
])
expect(
await rpcError(
acp.prompt(acp.sessionId, [
{ type: "text", text: "look" },
{ type: "image", data: "", mimeType: "image/png" },
]),
),
).toMatchObject({ code: -32602, message: expect.stringContaining("image content has no data or uri") })
expect(acp.server.submissions).toHaveLength(2)
})
test.each<[string, (sessionID: string) => OpenCodeEventEncoded, object]>([
[
"an execution auth failure",
(sessionID) => failed(sessionID, { type: "provider.auth", message: "missing key" }),
{ code: -32000 },
],
[
"a step auth failure",
(sessionID) =>
durableEvent("session.step.failed", {
sessionID,
assistantMessageID: "msg_auth",
error: { type: "provider.auth", message: "expired" },
}),
{ code: -32000 },
],
[
"another execution failure",
(sessionID) => failed(sessionID, { type: "provider.rate-limit", message: "slow down" }),
{ code: -32603, message: expect.stringContaining("slow down") },
],
])("maps %s to an ACP error", async (_, failure, expected) => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), retryScheduled(sessionID, 1, at)],
onInterrupt: held.onInterrupt,
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
textDelta(sessionID, "msg_auth", "partial"),
failure(sessionID),
succeeded(sessionID),
],
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: { "opencode/retry": retryMeta(1, at) } })
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject(expected)
})
test("session/cancel before admission aborts the submission and returns cancelled", async () => {
@@ -369,45 +280,29 @@ describe("acp prompt turns over the wire", () => {
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
await aborted.promise
expect(acp.server.interrupts).toContain(acp.sessionId)
})
test("session/cancel before admission returns interrupts the session exactly once", async () => {
await using acp = await startSession({
onPrompt: ({ signal }) =>
new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.server.submissions.length === 1, "prompt submission")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
expect(acp.server.interrupts).toEqual([acp.sessionId])
})
test("session/cancel mid-turn interrupts the session once, returns cancelled, and keeps it usable", async () => {
await using acp = await startSession(held)
const prompt = acp.prompt(acp.sessionId, "hold")
await admitted(acp, acp.sessionId)
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
expect(acp.server.interrupts).toEqual([acp.sessionId])
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
})
test("session/cancel forwards the server's wind-down before resolving cancelled", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_sleep", "shell"),
toolCalled(sessionID, "call_sleep", { command: "sleep 60" }),
textDelta(sessionID, "msg_held", "working"),
],
capabilities: { compaction: {} },
onPrompt: ({ sessionID, id, text }) =>
text === "again"
? turn(sessionID, id)
: [
delivered(sessionID, id),
toolStarted(sessionID, "call_sleep", "shell"),
toolCalled(sessionID, "call_sleep", { command: "sleep 60" }),
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
textDelta(sessionID, "msg_held", "working"),
],
onInterrupt: ({ sessionID }) => [
toolFailed(sessionID, "call_sleep", { error: { type: "aborted", message: "interrupted" } }),
durableEvent("session.compaction.failed", {
sessionID,
reason: "manual",
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}),
durableEvent("session.step.failed", {
sessionID,
assistantMessageID: "msg_held",
@@ -428,22 +323,31 @@ describe("acp prompt turns over the wire", () => {
usage: { inputTokens: 30, outputTokens: 3, totalTokens: 33 },
_meta: {},
})
expect(receivedBeforeResponse(acp)).toEqual(
const before = receivedBeforeResponse(acp)
expect(before).toEqual(
expect.arrayContaining([
expect.objectContaining({ sessionUpdate: "tool_call_update", toolCallId: "call_sleep", status: "failed" }),
expect.objectContaining({ sessionUpdate: "usage_update", used: 33 }),
]),
)
expect(before.filter((update) => update.sessionUpdate === "compaction_update")).toMatchObject([
{ status: "in_progress" },
{ status: "cancelled", summary: null },
])
expect(acp.server.interrupts).toEqual([acp.sessionId])
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
})
test("stops waiting for a wind-down that never ends and fails the tools left running", async () => {
await using acp = await startSession({
capabilities: { compaction: {} },
cancelDrainTimeout: "50 millis",
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_stuck", "shell"),
toolCalled(sessionID, "call_stuck", { command: "sleep 60" }),
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
textDelta(sessionID, "msg_held", "working"),
],
})
@@ -453,7 +357,9 @@ describe("acp prompt turns over the wire", () => {
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
expect(receivedBeforeResponse(acp)).toContainEqual(
const compactionId = acp.server.prompts[0]?.id
const before = receivedBeforeResponse(acp)
expect(before).toContainEqual(
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId: "call_stuck",
@@ -461,37 +367,11 @@ describe("acp prompt turns over the wire", () => {
rawOutput: expect.objectContaining({ error: "Cancelled" }),
}),
)
})
test("session/close interrupts a slash command still running after its prompt ended", async () => {
await using acp = await startSession()
expect((await acp.prompt(acp.sessionId, "/review now")).stopReason).toBe("end_turn")
expect(acp.server.interrupts).toEqual([])
await acp.request("session/close", { sessionId: acp.sessionId })
expect(acp.server.interrupts).toEqual([acp.sessionId])
})
test("fails the prompt as server unavailable when the event stream ends mid-turn", async () => {
await using acp = await startSession(held)
const prompt = acp.prompt(acp.sessionId, "hold")
await admitted(acp, acp.sessionId)
acp.server.closeEvents()
expect(await rpcError(prompt)).toMatchObject({ code: -32603, data: { errorName: "ServerUnavailable" } })
})
test("fails the prompt as server unavailable when the event stream's connection drops mid-turn", async () => {
await using acp = await startSession(held)
const prompt = acp.prompt(acp.sessionId, "hold")
await admitted(acp, acp.sessionId)
acp.server.dropEvents()
expect(await rpcError(prompt)).toMatchObject({ code: -32603, data: { errorName: "ServerUnavailable" } })
expect(acp.logs).toEqual([])
expect(before.filter((update) => update.sessionUpdate.startsWith("compaction_"))).toEqual([
{ sessionUpdate: "compaction_update", compactionId, status: "in_progress" },
{ sessionUpdate: "compaction_summary_chunk", compactionId, content: { type: "text", text: "partial" } },
{ sessionUpdate: "compaction_update", compactionId, status: "cancelled", summary: null },
])
})
test("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
@@ -531,123 +411,10 @@ describe("acp prompt turns over the wire", () => {
data: { sessionId: acp.sessionId },
})
expect((await acp.prompt(other.sessionId, "still here")).stopReason).toBe("end_turn")
})
test("rejects a second prompt while the session already has an active turn", async () => {
await using acp = await startSession(held)
const first = acp.prompt(acp.sessionId, "hold")
await acp.until(() => acp.server.submissions.length === 1, "first prompt")
expect(await rpcError(acp.prompt(acp.sessionId, "second"))).toMatchObject({ code: -32603 })
expect(acp.server.submissions).toHaveLength(1)
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await first).stopReason).toBe("cancelled")
})
test("reports usage summed across every step of the turn", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
})
test("publishes the last step's context usage rather than the turn sum", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
stepEnded(sessionID, "msg_step_1", {
finish: "tool-calls",
tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 50 } },
}),
stepEnded(sessionID, "msg_step_2", {
tokens: { input: 20, output: 5, reasoning: 3, cache: { read: 150, write: 0 } },
}),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(response.usage).toEqual({
inputTokens: 120,
outputTokens: 15,
thoughtTokens: 3,
cachedReadTokens: 150,
cachedWriteTokens: 50,
totalTokens: 338,
})
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
sessionId: acp.sessionId,
update: { sessionUpdate: "usage_update", used: 178, size: 100_000, cost: { amount: 0, currency: "USD" } },
})
})
test("counts a failed step's tokens and clears its error when the next step starts", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.step.failed", {
sessionID,
assistantMessageID: "msg_1",
error: { type: "provider.stream", message: "stream interrupted" },
cost: 0,
tokens: { ...tokens(), input: 40, output: 4 },
}),
durableEvent("session.step.started", {
sessionID,
assistantMessageID: "msg_2",
agent: "build",
model: { providerID: "test", id: "test-model" },
started: 0,
}),
stepEnded(sessionID, "msg_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(response).toEqual({
stopReason: "end_turn",
usage: { inputTokens: 60, outputTokens: 11, totalTokens: 71 },
_meta: {},
})
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
update: { used: 27 },
})
})
test("excludes child session steps from the turn usage", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
stepEnded("ses_child", "msg_child", { tokens: { ...tokens(), input: 500, output: 50 } }),
succeeded("ses_child"),
stepEnded(sessionID, "msg_root", { tokens: { ...tokens(), input: 20, output: 7 } }),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(response.usage).toEqual({ inputTokens: 20, outputTokens: 7, totalTokens: 27 })
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
update: { used: 27 },
})
expect((await acp.prompt(other.sessionId, "/review now")).stopReason).toBe("end_turn")
await acp.request("session/close", { sessionId: other.sessionId })
expect(acp.server.interrupts).toEqual([acp.sessionId, other.sessionId])
})
})
@@ -672,20 +439,8 @@ function receivedBeforeResponse(acp: Wire) {
return acp.updates.slice(0, count).map((item) => item.update)
}
function retryScheduled(sessionID: string, attempt: number, at: number) {
return durableEvent("session.retry.scheduled", {
sessionID,
assistantMessageID: "msg_retry",
attempt,
at,
error: { type: "provider.rate-limit", message: "rate limited" },
})
}
function retryMeta(attempt: number, at: number) {
return {
attempt,
nextRetryAt: new Date(at).toISOString(),
error: { type: "provider.rate-limit", message: "rate limited" },
}
function turnUpdates(updates: readonly SessionNotification[]) {
return updates.filter(
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
)
}
@@ -1,52 +0,0 @@
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { Option, Schema } from "effect"
import { ACPElicitation } from "../../src/acp/elicitation"
import { createAcpFixture, expectOk, initialize, newSession, toolCallStream } from "./subprocess"
const ChatRequest = Schema.Struct({
messages: Schema.Array(
Schema.Struct({ role: Schema.String, tool_call_id: Schema.optional(Schema.String), content: Schema.Unknown }),
),
})
// The first completion asks a question; the follow-up completion ends the turn.
function askingModel(request: unknown) {
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
return toolCallStream("call_question", "question", {
questions: [
{
header: "Runtime",
question: "Which runtime?",
options: [
{ label: "Bun", description: "Fast" },
{ label: "Node", description: "Stable" },
],
},
],
})
}
describe("acp question subprocess", () => {
test("a question the client cannot show returns to the model and the turn continues", async () => {
await using fixture = await createAcpFixture({ respond: askingModel })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const result = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "ask me something" }],
}),
)
expect(result.stopReason).toBe("end_turn")
const results = fixture.llm.requests
.flatMap((request) => Option.toArray(Schema.decodeUnknownOption(ChatRequest)(request)))
.flatMap((request) => request.messages)
.filter((message) => message.role === "tool" && message.tool_call_id === "call_question")
expect(results).toHaveLength(1)
expect(String(results[0]?.content)).toContain(ACPElicitation.UnshownQuestionMessage)
}, 60_000)
})
+1 -43
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessage } from "@opencode/schema/session-message"
import { assistantMessage, makeSession, rpcError, startWire } from "./wire-fixture"
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
describe("acp session replay over the wire", () => {
test("replays user, text, reasoning, and tool messages in order on session/load", async () => {
@@ -59,50 +59,8 @@ describe("acp session replay over the wire", () => {
})
expect(updates[11]?.update).toMatchObject({ toolCallId: "call_streaming", status: "pending", rawInput: {} })
})
test("fails the load and detaches when a message page does not decode", async () => {
await using acp = await startWire({
fetch(request) {
if (request.path !== "/api/session/ses_replay_failure/message") return undefined
return Response.json({
data: [
// An errored tool without its error does not decode.
replayToolMessage("call_first", { status: "error", input: {}, metadata: {} }),
replayToolMessage("call_after", {
status: "completed",
input: { command: "printf done" },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
}),
],
cursor: {},
})
},
})
acp.server.sessions.set("ses_replay_failure", makeSession("ses_replay_failure"))
await acp.initialize()
expect(
await rpcError(
acp.request("session/load", { cwd: "/workspace", sessionId: "ses_replay_failure", mcpServers: [] }),
),
).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
expect(acp.updates.filter((item) => item.update.sessionUpdate !== "available_commands_update")).toEqual([])
expect(
await rpcError(
acp.request("session/set_config_option", { sessionId: "ses_replay_failure", configId: "mode", value: "plan" }),
),
).toMatchObject({ code: -32602, data: { sessionId: "ses_replay_failure" } })
})
})
function replayToolMessage(id: string, state: Record<string, unknown>) {
return {
...assistantMessage(`msg_${id}`),
content: [{ type: "tool", id, name: "shell", time: { created: 1, completed: 2 }, state }],
}
}
function replayFixtureMessages(): Array<typeof SessionMessage.Info.Encoded> {
return [
{
@@ -1,35 +0,0 @@
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
describe("acp retry subprocess", () => {
test("reports provider retries during the turn and on cancellation", async () => {
await using fixture = await createAcpFixture({
respond: () =>
Response.json(
{ error: { message: "rate-limited upstream", type: "rate_limit_error" } },
// A long backoff keeps the retry pending until the cancel lands.
{ status: 429, headers: { "retry-after": "30" } },
),
})
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const prompt = acp.send<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello" }],
})
const scheduled = await acp.waitForNotification<SessionNotification>(
"session/update",
(params) => params.sessionId === session.sessionId && params.update.sessionUpdate === "session_info_update",
)
const retry = scheduled.params.update._meta?.["opencode/retry"]
expect(retry).toMatchObject({ attempt: 2, error: { message: expect.stringContaining("rate-limited upstream") } })
await acp.notify("session/cancel", { sessionId: session.sessionId })
const response = expectOk(await prompt.response)
expect(response.stopReason).toBe("cancelled")
expect(response._meta?.["opencode/retry"]).toEqual(retry)
}, 60_000)
})
+1 -27
View File
@@ -1,30 +1,4 @@
import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"
type SelectOption = Extract<SessionConfigOption, { type: "select" }>
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
return options?.find((option): option is SelectOption => option.id === id && option.type === "select")
}
export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
if (!option) throw new Error(`Missing ACP config option: ${id}`)
return option
}
export function flattenSelectOptions(option: SelectOption) {
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
}
export function selectValues(options: SessionConfigOption[] | null | undefined, id: string) {
return flattenSelectOptions(requireSelectOption(options, id)).map((option) => option.value)
}
export function alternateValue(option: SelectOption) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
return value
}
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
export function currentValue(
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
-202
View File
@@ -1,202 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import { currentValue, selectValues } from "./select-options"
import {
durableEvent,
ephemeralEvent,
secondModel,
startSession,
startWire,
stepEnded,
testModel,
turn,
type Wire,
} from "./wire-fixture"
describe("acp follows model and agent selections from other clients", () => {
test("pushes an external model switch with its variant and uses it for the next prompt", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => turn(sessionID, id, stepEnded(sessionID, "msg_assistant")),
})
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id, variant: "low" },
previous: { providerID: "test", id: "test-model" },
}),
)
const [options] = await optionUpdates(acp, acp.sessionId, 1)
await acp.prompt(acp.sessionId, "hello")
expect(values(options)).toEqual({ model: "test/second-model", effort: "low", mode: "build" })
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
update: { size: secondModel.limit.context },
})
})
test("shows the default effort when the external switch carries no variant", async () => {
await using acp = await startSession()
await acp.request("session/set_config_option", { sessionId: acp.sessionId, configId: "effort", value: "high" })
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id },
}),
)
expect(values((await optionUpdates(acp, acp.sessionId, 1))[0])).toEqual({
model: "test/second-model",
effort: "default",
mode: "build",
})
})
test("pushes an external agent switch as the mode", async () => {
await using acp = await startSession()
acp.server.send(
durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan", previous: "build" }),
)
expect(values((await optionUpdates(acp, acp.sessionId, 1))[0])).toEqual({
model: "test/test-model",
effort: "default",
mode: "plan",
})
})
test("does not echo its own model and mode switches", async () => {
await using acp = await startSession({
// The server publishes the selection event before it answers the switch.
fetch: (request) => {
if (request.method !== "POST") return undefined
if (request.path.endsWith("/model"))
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id, variant: "medium" },
}),
)
if (request.path.endsWith("/agent"))
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan" }))
return undefined
},
})
const set = (configId: string, value: string) =>
acp.request("session/set_config_option", { sessionId: acp.sessionId, configId, value })
await set("model", "test/second-model/medium")
await acp.request("session/set_mode", { sessionId: acp.sessionId, modeId: "plan" })
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "build" }))
const updates = await optionUpdates(acp, acp.sessionId, 1)
expect(updates.map(values)).toEqual([{ model: "test/second-model", effort: "medium", mode: "build" }])
})
test("ignores selections for other sessions", async () => {
await using acp = await startSession()
const other = await acp.newSession()
acp.server.send(durableEvent("session.agent.selected", { sessionID: "ses_unattached", agent: "plan" }))
acp.server.send(durableEvent("session.agent.selected", { sessionID: other.sessionId, agent: "plan" }))
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id },
}),
)
const updates = await optionUpdates(acp, acp.sessionId, 1)
expect(updates.map(values)).toEqual([{ model: "test/second-model", effort: "default", mode: "build" }])
})
test("stops following a closed session and follows it once after re-attaching", async () => {
await using acp = await startSession()
const other = await acp.newSession()
await acp.request("session/close", { sessionId: acp.sessionId })
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan" }))
acp.server.send(durableEvent("session.agent.selected", { sessionID: other.sessionId, agent: "plan" }))
await optionUpdates(acp, other.sessionId, 1)
expect(configUpdates(acp, acp.sessionId)).toEqual([])
const stored = acp.server.sessions.get(acp.sessionId)
if (!stored) throw new Error(`missing stored session ${acp.sessionId}`)
acp.server.sessions.set(acp.sessionId, { ...stored, agent: "plan" })
const resume = () => acp.request("session/resume", { sessionId: acp.sessionId, cwd: "/workspace" })
const resumed = await resume()
await resume()
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id },
}),
)
await optionUpdates(acp, acp.sessionId, 1)
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "build" }))
await optionUpdates(acp, acp.sessionId, 2)
expect(currentValue(resumed, "mode")).toBe("plan")
expect(configUpdates(acp, acp.sessionId).map(values)).toEqual([
{ model: "test/second-model", effort: "default", mode: "plan" },
{ model: "test/second-model", effort: "default", mode: "build" },
])
})
test("ends on the latest catalog and selection when both change together", async () => {
await using acp = await startWire()
acp.server.catalog.models = [testModel]
await acp.initialize()
const { sessionId } = await acp.newSession()
acp.server.catalog.models = [testModel, secondModel]
acp.server.send(
ephemeralEvent("model.updated", {}),
durableEvent("session.model.selected", {
sessionID: sessionId,
model: { providerID: "test", id: secondModel.id },
}),
)
await acp.until(
() => configUpdates(acp, sessionId).some((options) => selectValues(options, "model").length === 2),
"the reloaded catalog",
)
acp.server.send(durableEvent("session.agent.selected", { sessionID: sessionId, agent: "plan" }))
const updates = await acp.until(() => {
const updates = configUpdates(acp, sessionId)
return values(updates.at(-1)).mode === "plan" && updates
}, "the sentinel update")
expect(
updates.slice(-2).map((options) => ({ ...values(options), models: selectValues(options, "model") })),
).toEqual(
["build", "plan"].map((mode) => ({
model: "test/second-model",
effort: "default",
mode,
models: ["test/second-model", "test/test-model"],
})),
)
})
})
function configUpdates(acp: Wire, sessionId: string) {
return acp.updates.flatMap((item) =>
item.sessionId === sessionId && item.update.sessionUpdate === "config_option_update"
? [item.update.configOptions]
: [],
)
}
function optionUpdates(acp: Wire, sessionId: string, count: number) {
return acp.until(() => {
const updates = configUpdates(acp, sessionId)
return updates.length >= count && updates
}, `${count} config_option_update for ${sessionId}`)
}
function values(options: SessionConfigOption[] | undefined) {
return Object.fromEntries((options ?? []).map((option) => [option.id, option.currentValue]))
}
+33 -236
View File
@@ -1,20 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionNotification } from "@agentclientprotocol/sdk"
import type { McpServer } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
import { currentValue } from "./select-options"
import {
ephemeralEvent,
makeSession,
rpcError,
secondModel,
startSession,
startWire,
testModel,
type Wire,
} from "./wire-fixture"
import { makeSession, rpcError, secondModel, startWire, type Wire } from "./wire-fixture"
describe("acp session lifecycle over the wire", () => {
test("initialize advertises capabilities and terminal auth only when the client asks", async () => {
test("initialize advertises capabilities, negotiates the version, and offers terminal auth only when asked", async () => {
await using acp = await startWire()
const plain = await acp.initialize()
@@ -42,48 +33,7 @@ describe("acp session lifecycle over the wire", () => {
code: -32602,
data: { methodId: "missing" },
})
})
test("creates a v2 session, registers mcp, and publishes commands", async () => {
await using acp = await startWire()
acp.server.catalog.commands = [{ name: "review" }]
await acp.initialize()
const result = await acp.newSession("/workspace", [
{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] },
])
expect(acp.server.sessions.get(result.sessionId)?.location.directory).toBe("/workspace")
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(acp.server.mcp).toEqual([
{
name: "docs",
directory: "/workspace",
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
},
])
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).toEqual({
sessionId: result.sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "" },
{ name: "compact", description: "Compact the session" },
],
},
})
})
test("does not persist the first catalog variant when no explicit default exists", async () => {
await using acp = await startWire()
acp.server.catalog.models = [{ ...secondModel, variants: [{ id: "none" }, { id: "high" }] }]
await acp.initialize()
const created = await acp.newSession()
expect(currentValue(created, "effort")).toBe("default")
expect(acp.server.sessions.get(created.sessionId)?.model).toBeUndefined()
expect(acp.server.selections).toEqual([])
expect((await acp.request("initialize", { protocolVersion: 99 })).protocolVersion).toBe(1)
})
test("loads with paginated replay while resume and fork do not replay", async () => {
@@ -167,156 +117,6 @@ describe("acp session lifecycle over the wire", () => {
])
})
test("does not publish commands for a session closed before its load responds", async () => {
const held = Promise.withResolvers<undefined>()
await using acp = await startWire({
fetch: (request) => (request.path === "/api/session/ses_loaded/message" ? held.promise : undefined),
})
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
await acp.initialize()
const loaded = acp.request("session/load", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })
await acp.until(() => acp.server.requests.some((item) => item.path === "/api/session/ses_loaded/message"))
await acp.request("session/close", { sessionId: "ses_loaded" })
held.resolve(undefined)
await loaded
// A later session's commands follow the load response, giving a stray update time to arrive.
const created = await acp.newSession()
await acp.until(() => acp.updates.some(isCommands), "commands for the later session")
expect(acp.updates.filter(isCommands).map((item) => item.sessionId)).toEqual([created.sessionId])
})
test("detaches a session whose load fails after attaching", async () => {
await using acp = await startWire({
fetch: (request) =>
request.path === "/api/session/ses_loaded/message" ? new Response(null, { status: 500 }) : undefined,
})
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
await acp.initialize()
expect(
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })),
).toMatchObject({ code: -32603 })
expect(
await rpcError(
acp.request("session/set_config_option", { sessionId: "ses_loaded", configId: "mode", value: "plan" }),
),
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded" } })
})
test("fails new and resume before attaching when the catalog does not decode", async () => {
// The client rejects a model without a name, so the catalog fails to load.
await using acp = await startWire({
fetch: (request) =>
request.path === "/api/model"
? Response.json({
location: { directory: "/workspace", project: { id: "global", directory: "/workspace" } },
data: [testModel, { ...secondModel, name: null }],
})
: undefined,
})
acp.server.sessions.set("ses_resumed", makeSession("ses_resumed"))
await acp.initialize()
const existing = new Set(acp.server.sessions.keys())
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
expect(new Set(acp.server.sessions.keys())).toEqual(existing)
expect(
await rpcError(acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_resumed", mcpServers: [] })),
).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
expect(
await rpcError(
acp.request("session/set_config_option", { sessionId: "ses_resumed", configId: "mode", value: "plan" }),
),
).toMatchObject({ code: -32602, data: { sessionId: "ses_resumed" } })
expect(acp.updates).toEqual([])
})
test("lists server-backed pages for the requested cwd", async () => {
await using acp = await startWire()
Array.from({ length: 101 }, (_, index) =>
makeSession(`ses_${index}`, { time: { created: index, updated: 1_000 + index } }),
).forEach((session) => acp.server.sessions.set(session.id, session))
acp.server.sessions.set(
"ses_other",
makeSession("ses_other", { cwd: "/other", time: { created: 0, updated: 9_999 } }),
)
await acp.initialize()
const first = await acp.request("session/list", { cwd: "/workspace" })
const second = await acp.request("session/list", { cwd: "/workspace", cursor: first.nextCursor })
expect(first.sessions).toHaveLength(100)
expect(first.sessions[0]).toEqual({
sessionId: "ses_100",
cwd: "/workspace",
title: "Session ses_100",
updatedAt: new Date(1_100).toISOString(),
})
expect(first.nextCursor).toBeDefined()
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
expect(second.nextCursor).toBeUndefined()
})
test("cancel keeps an idle session attached while close detaches it", async () => {
await using acp = await startSession()
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await acp.prompt(acp.sessionId, "after cancel")).stopReason).toBe("end_turn")
expect(await acp.request("session/close", { sessionId: acp.sessionId })).toEqual({})
expect(await rpcError(acp.prompt(acp.sessionId, "after close"))).toMatchObject({
code: -32602,
data: { sessionId: acp.sessionId },
})
expect(await acp.request("session/close", { sessionId: "missing" })).toEqual({})
})
test("deletes sessions from backing and local storage", async () => {
await using acp = await startSession()
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
expect(acp.server.sessions.has(acp.sessionId)).toBe(false)
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
expect(await acp.request("session/delete", { sessionId: "ses_never_created" })).toEqual({})
expect(await acp.request("session/delete", { sessionId: "never-created" })).toEqual({})
expect(
await rpcError(
acp.request("session/set_config_option", { sessionId: acp.sessionId, configId: "effort", value: "high" }),
),
).toMatchObject({ code: -32602, data: { sessionId: acp.sessionId } })
})
test("rejects malformed session IDs as invalid params", async () => {
await using acp = await startWire()
await acp.initialize()
const params = { cwd: "/workspace", sessionId: "never-created", mcpServers: [] }
const invalid = { code: -32602, message: "Invalid params: Invalid session ID", data: { field: "sessionID" } }
expect(await rpcError(acp.request("session/load", params))).toEqual(invalid)
expect(await rpcError(acp.request("session/resume", params))).toEqual(invalid)
expect(await rpcError(acp.request("session/fork", params))).toEqual(invalid)
expect(acp.server.requests.filter((request) => request.path.includes("never-created"))).toEqual([])
expect(acp.logs).toEqual([])
})
test("rejects forking an unknown session as session not found", async () => {
await using acp = await startWire()
await acp.initialize()
expect(
await rpcError(acp.request("session/fork", { cwd: "/workspace", sessionId: "ses_unknown", mcpServers: [] })),
).toEqual({
code: -32602,
message: "Invalid params: session not found: ses_unknown",
data: { sessionId: "ses_unknown" },
})
expect(acp.logs).toEqual([])
})
test("converts MCP configs and deduplicates registrations per session and config", async () => {
const local: McpServer = {
name: "tools",
@@ -337,54 +137,55 @@ describe("acp session lifecycle over the wire", () => {
const first = await acp.newSession("/workspace", [local, local, remote])
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [local, remote] })
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [changed] })
await acp.newSession("/workspace", [local])
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [local] })
const localConfig = (args: string[]) => ({
name: "tools",
directory: "/workspace",
config: { type: "local", command: ["bun", ...args], environment: { TOKEN: "x" } },
})
expect(acp.server.mcp).toHaveLength(4)
expect(acp.server.mcp.filter((item) => item.name === "tools")).toEqual([
localConfig(["server.ts"]),
localConfig(["changed.ts"]),
localConfig(["server.ts"]),
])
await acp.newSession("/workspace", [local])
expect(acp.server.mcp).toHaveLength(5)
expect(acp.server.mcp.filter((item) => item.name === "tools")).toEqual([
localConfig(["server.ts"]),
localConfig(["changed.ts"]),
localConfig(["server.ts"]),
localConfig(["server.ts"]),
])
expect(acp.server.mcp.find((item) => item.name === "docs")).toEqual({
name: "docs",
directory: "/workspace",
config: { type: "remote", url: "https://example.com/mcp", headers: { Authorization: "Bearer x" }, oauth: false },
})
})
test("leaves a session detached when re-attaching it fails", async () => {
const broken: McpServer = { name: "broken", command: "bun", args: [], env: [] }
await using acp = await startWire({
fetch: (request) =>
request.method === "PUT" && request.path === "/api/experimental/mcp/broken"
? new Response(null, { status: 500 })
: undefined,
})
test("rejects MCP-over-ACP and SSE servers before creating or loading a session", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_saved", makeSession("ses_saved"))
await acp.initialize()
const failed = await acp.newSession()
const other = await acp.newSession()
await acp.until(() => acp.updates.filter(isCommands).length === 2, "initial commands")
const existing = new Set(acp.server.sessions.keys())
const mcpServers: McpServer[] = [{ type: "acp", name: "client", serverId: "mcp_client" }]
const sse: McpServer[] = [{ type: "sse", name: "events", url: "https://example.com/sse", headers: [] }]
const invalid = {
code: -32602,
message: "Invalid params: Only stdio and HTTP MCP servers are supported",
data: { field: "mcpServers" },
}
expect(await rpcError(acp.newSession("/workspace", mcpServers))).toEqual(invalid)
expect(await rpcError(acp.newSession("/workspace", sse))).toEqual(invalid)
expect(
await rpcError(
acp.request("session/resume", { cwd: "/workspace", sessionId: failed.sessionId, mcpServers: [broken] }),
),
).toMatchObject({ code: -32603 })
const since = acp.updates.length
acp.server.catalog.models = [testModel]
acp.server.send(ephemeralEvent("model.updated", {}))
await acp.until(() => acp.updates.length > since, "config options for the attached session")
expect(acp.updates.slice(since).map((item) => item.sessionId)).toEqual([other.sessionId])
expect(
await rpcError(
acp.request("session/set_config_option", { sessionId: failed.sessionId, configId: "mode", value: "plan" }),
),
).toMatchObject({ code: -32602, data: { sessionId: failed.sessionId } })
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers })),
).toEqual(invalid)
expect(new Set(acp.server.sessions.keys())).toEqual(existing)
expect(acp.server.requests.filter((request) => request.path.includes("ses_saved"))).toEqual([])
expect(acp.logs).toEqual([])
})
})
@@ -395,10 +196,6 @@ const isSessionUpdate = Schema.is(
}),
)
function isCommands(item: SessionNotification) {
return item.update.sessionUpdate === "available_commands_update"
}
// Labels what the agent sends from the request until the commands that follow it, in wire order.
async function untilCommands(acp: Wire, send: () => Promise<unknown>) {
const start = acp.received.length
+15 -65
View File
@@ -11,25 +11,19 @@ type JsonRpcRequest = {
readonly params?: unknown
}
export type JsonRpcError = {
type JsonRpcError = {
readonly code: number
readonly message?: string
readonly data?: unknown
}
export type JsonRpcResponse<T> = {
type JsonRpcResponse<T> = {
readonly jsonrpc: "2.0"
readonly id: number
readonly result?: T
readonly error?: JsonRpcError
}
type JsonRpcNotification<T> = {
readonly jsonrpc: "2.0"
readonly method: string
readonly params: T
}
type JsonRpcMessage = Record<string, unknown>
type Waiter = {
@@ -39,36 +33,17 @@ type Waiter = {
readonly timer: ReturnType<typeof setTimeout>
}
export type AcpProcess = {
type AcpProcess = {
readonly pid: number
readonly exited: Promise<number>
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly send: <T>(
method: string,
params?: unknown,
) => { readonly id: number; readonly response: Promise<JsonRpcResponse<T>> }
readonly notify: (method: string, params: unknown) => Promise<void>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs?: number,
) => Promise<JsonRpcNotification<T>>
readonly close: () => Promise<number>
readonly stderr: () => string
readonly [Symbol.asyncDispose]: () => Promise<void>
}
export const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`
export async function createAcpFixture(
options: {
readonly skill?: string
readonly respond?: (request: unknown) => string | Response | Promise<string | Response>
} = {},
) {
@@ -76,12 +51,7 @@ export async function createAcpFixture(
const home = path.join(root, "workspace")
const config = path.join(root, "config")
const models = path.join(root, "models.json")
const skills = path.join(root, "skills")
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
if (options.skill) {
await fs.mkdir(path.join(skills, "verifier-skill"), { recursive: true })
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
}
const requests: unknown[] = []
const llm = Bun.serve({
@@ -100,10 +70,7 @@ export async function createAcpFixture(
})
},
})
await Bun.write(
path.join(config, "opencode.json"),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
)
await Bun.write(path.join(config, "opencode.json"), JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`)))
await Bun.write(models, "{}")
const processes = new Set<AcpProcess>()
@@ -111,7 +78,7 @@ export async function createAcpFixture(
root,
home,
llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) {
spawn() {
const acp = spawnAcp({
env: isolatedEnv(root, {
USERPROFILE: root,
@@ -119,7 +86,6 @@ export async function createAcpFixture(
OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_MODELS_PATH: models,
...extraEnv,
}),
})
processes.add(acp)
@@ -167,7 +133,10 @@ export function toolCallStream(id: string, name: string, input: unknown) {
{
choices: [
{
delta: { role: "assistant", tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
delta: {
role: "assistant",
tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }],
},
finish_reason: null,
},
],
@@ -185,7 +154,7 @@ export function toolCallStream(id: string, name: string, input: unknown) {
})
}
function verifierConfig(llmUrl: string, skills?: string) {
function verifierConfig(llmUrl: string) {
const model = {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: { input: 0, output: 0 },
@@ -194,7 +163,6 @@ function verifierConfig(llmUrl: string, skills?: string) {
return {
update: "disable",
model: "test/test-model",
...(skills ? { skills: [skills] } : {}),
providers: {
test: {
name: "Test",
@@ -305,41 +273,27 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
})
}
const write = async (message: JsonRpcRequest | JsonRpcNotification<unknown>) => {
const write = async (message: JsonRpcRequest) => {
if (inputClosed) throw new Error("ACP stdin is closed")
await child.stdin.write(encoder.encode(`${JSON.stringify(message)}\n`))
await child.stdin.flush()
}
const send = <T>(method: string, params?: unknown) => {
const request = <T>(method: string, params?: unknown) => {
const id = nextID++
const request: JsonRpcRequest =
const message: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
const response = write(request).then(async () => {
return write(message).then(async () => {
const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
})
return { id, response }
}
return {
pid: child.pid,
exited: child.exited,
request: <T>(method: string, params?: unknown) => send<T>(method, params).response,
send,
notify: (method: string, params: unknown) => write({ jsonrpc: "2.0", method, params }),
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
const notification = await take(
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
timeoutMs,
`${method} notification`,
)
if (!isNotification<T>(notification)) {
throw new Error(`Invalid ACP notification: ${JSON.stringify(notification)}`)
}
return notification
},
request,
async close() {
if (!inputClosed) {
inputClosed = true
@@ -375,10 +329,6 @@ function isResponse<T>(message: JsonRpcMessage): message is JsonRpcMessage & Jso
return message.jsonrpc === "2.0" && typeof message.id === "number" && !("method" in message)
}
function isNotification<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcNotification<T> {
return message.jsonrpc === "2.0" && typeof message.method === "string" && !("id" in message)
}
function asError(error: unknown) {
return error instanceof Error ? error : new Error(String(error))
}
-391
View File
@@ -1,391 +0,0 @@
import { resolve } from "node:path"
import { describe, expect, test } from "bun:test"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
toLocations,
toToolKind,
} from "../../src/acp/tool"
describe("acp tools", () => {
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", { path: "/tmp/a.ts" }, "/workspace")).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { path: "/tmp/b.ts", oldString: "a", newString: "b" }, "/workspace")).toEqual([
{ path: "/tmp/b.ts" },
])
expect(toLocations("write", { path: "/tmp/c.ts", content: "c" }, "/workspace")).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("read", { filePath: "/tmp/v1.ts" }, "/workspace")).toEqual([{ path: "/tmp/v1.ts" }])
expect(toLocations("edit", { path: "src/b.ts" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "src/b.ts") },
])
expect(toLocations("grep", { path: "/repo/src" }, "/workspace")).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" }, "/workspace")).toEqual([{ path: "/repo/test" }])
expect(toLocations("grep", { pattern: "x", path: "src" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "src") },
])
expect(toLocations("glob", { pattern: "*.ts" }, "/workspace")).toEqual([])
expect(toLocations("context7_get_library_docs", { path: "/docs" }, "/workspace")).toEqual([{ path: "/docs" }])
expect(
toLocations("external_directory", { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" }, "/workspace"),
).toEqual([{ path: "/tmp/outside/a.ts" }])
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "subdir") },
])
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" }, "/workspace")).toEqual([
{ path: "/tmp/missing-file-path.ts" },
])
})
test("extracts patch locations from every hunk in the patch body", () => {
const patchText = [
"*** Begin Patch",
"*** Add File: src/new.ts",
"+created",
"*** Update File: /abs/old.ts",
"*** Move to: src/moved.ts",
"@@",
"-one",
"+two",
"*** Update File: src/same.ts",
"@@",
"-a",
"+b",
"*** Delete File: src/gone.ts",
"*** Update File: src/new.ts",
"@@",
"-created",
"+changed",
"*** End Patch",
].join("\n")
expect(toLocations("patch", { patchText }, "/workspace")).toEqual([
{ path: resolve("/workspace", "src/new.ts") },
{ path: "/abs/old.ts" },
{ path: resolve("/workspace", "src/moved.ts") },
{ path: resolve("/workspace", "src/same.ts") },
{ path: resolve("/workspace", "src/gone.ts") },
])
expect(toLocations("apply_patch", { patchText }, "/workspace")).toHaveLength(5)
})
test("returns no patch locations when the patch body cannot be parsed", () => {
expect(toLocations("patch", { patchText: "*** Update File: src/a.ts\n-a\n+b" }, "/workspace")).toEqual([])
expect(toLocations("patch", { patchText: "*** Begin Patch\nnot a hunk\n*** End Patch" }, "/workspace")).toEqual([])
expect(toLocations("patch", {}, "/workspace")).toEqual([])
})
test("reports resolved locations on completed and failed updates", () => {
const patchText = ["*** Begin Patch", "*** Add File: src/a.ts", "+a", "*** End Patch"].join("\n")
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "patch",
input: { patchText },
content: [{ type: "text", text: "patched" }],
cwd: "/workspace",
}).locations,
).toEqual([{ path: resolve("/workspace", "src/a.ts") }])
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "edit",
input: { path: "src/b.ts", oldString: "before", newString: "after" },
content: [],
cwd: "/workspace",
}),
).toMatchObject({
locations: [{ path: resolve("/workspace", "src/b.ts") }],
content: [{ type: "diff", path: resolve("/workspace", "src/b.ts"), oldText: "before", newText: "after" }],
})
expect(
errorToolUpdate({
toolCallId: "call",
toolName: "write",
input: { path: "src/c.ts", content: "c" },
error: "denied",
cwd: "/workspace",
}).locations,
).toEqual([{ path: resolve("/workspace", "src/c.ts") }])
})
test("builds completed content with text and image attachments", () => {
const image = Buffer.from("image-data").toString("base64")
expect(
completedToolUpdate({
toolCallId: "tool-1",
cwd: "/workspace",
toolName: "edit",
input: {
path: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
content: [
{ type: "text", text: "edited /tmp/file.ts" },
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
],
metadata: {},
}).content,
).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 when normalized content does not contain one", () => {
expect(
completedToolUpdate({
toolCallId: "tool-1",
cwd: "/workspace",
toolName: "write",
input: {
path: "/tmp/file.ts",
content: "created",
},
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
metadata: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "text", text: "wrote /tmp/file.ts" },
},
])
})
test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => {
expect(
completedToolUpdate({
toolCallId: "tool-read",
cwd: "/workspace",
toolName: "read",
input: { path: "/tmp/file.ts" },
content: [
{
type: "text",
text: JSON.stringify(
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
null,
2,
),
},
],
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
expect(
completedToolUpdate({
toolCallId: "tool-list",
cwd: "/workspace",
toolName: "read",
input: { path: "/tmp" },
content: [
{
type: "text",
text: JSON.stringify({
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
}),
},
],
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
})
test("sends completed tool calls as partial updates", () => {
expect(
pendingToolCall({
toolCallId: "tool-1",
cwd: "/workspace",
toolName: "edit",
state: {
input: {
path: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
},
}),
).toMatchObject({
toolCallId: "tool-1",
status: "pending",
kind: "edit",
locations: [{ path: "/tmp/file.ts" }],
rawInput: {
path: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
})
expect(
completedToolUpdate({
toolCallId: "tool-1",
cwd: "/workspace",
toolName: "edit",
input: {
path: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
content: [{ type: "text", text: "Edit applied successfully." }],
metadata: { output: "Edit applied successfully." },
}),
).toEqual({
toolCallId: "tool-1",
status: "completed",
locations: [{ path: "/tmp/file.ts" }],
content: [
{
type: "content",
content: { type: "text", text: "Edit applied successfully." },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
],
rawOutput: {
metadata: { output: "Edit applied successfully." },
},
})
})
test("builds running tool updates with normalized content", () => {
expect(
runningToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
state: { input: { path: "/tmp/a" } },
content: [{ type: "text", text: "done" }],
}),
).toMatchObject({
toolCallId: "call",
status: "in_progress",
locations: [{ path: "/tmp/a" }],
content: [{ type: "content", content: { type: "text", text: "done" } }],
})
})
test("builds completed raw output with optional metadata", () => {
const attachments = [
{
type: "file",
mime: "image/jpeg",
name: "photo.jpg",
uri: "data:image/jpeg;base64,AAAA",
},
]
expect(
completedToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
input: {},
content: [],
metadata: { output: "done", metadata: { exit: 0 }, attachments },
}).rawOutput,
).toEqual({
metadata: { output: "done", metadata: { exit: 0 }, attachments },
})
expect(
completedToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
input: {},
content: [],
}).rawOutput,
).toEqual({})
})
test("extracts image attachments only from data URLs", () => {
expect(
completedToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
input: {},
content: [
{ type: "file", mime: "image/webp", uri: "data:image/webp;charset=utf-8;base64,AAAA" },
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
],
metadata: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
},
])
})
test("builds failed tool updates", () => {
expect(
errorToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
input: { path: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
metadata: { path: "/tmp/a" },
error: "failed",
}),
).toEqual({
toolCallId: "call",
status: "failed",
kind: "read",
title: "read",
locations: [{ path: "/tmp/a" }],
rawInput: { path: "/tmp/a" },
content: [
{ type: "content", content: { type: "text", text: "partial output" } },
{ type: "content", content: { type: "text", text: "failed" } },
],
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
})
})
})
+596 -115
View File
@@ -3,20 +3,26 @@ import { OpenCodeEvent, type OpenCodeEventEncoded } from "@opencode/protocol/gro
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Schema } from "effect"
import path from "node:path"
import { ACPReplay } from "../../src/acp/replay"
import { ACPTranslate } from "../../src/acp/translate"
import {
assistantMessage,
childCreated,
delivered,
durableEvent,
ephemeralEvent,
failed,
interrupted,
reasoningDelta,
stepEnded,
succeeded,
textDelta,
tokens,
toolCalled,
toolFailed,
toolProgress,
toolStarted,
toolSucceeded,
} from "./wire-fixture"
const root = Session.ID.make("ses_root")
@@ -28,57 +34,161 @@ const ctx: ACPTranslate.Context = {
compaction: false,
mode: "turn",
}
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx, state = ACPTranslate.initial) {
return events.reduce<{ state: ACPTranslate.TurnState; outputs: ACPTranslate.Output[]; terminal?: string }>(
(acc, event, index) => {
const next = ACPTranslate.step(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
return {
state: next.state,
outputs: [...acc.outputs, ...next.outputs],
...(next.terminal ? { terminal: next.terminal } : {}),
}
},
{ state, outputs: [] },
)
}
const decodeEvent = Schema.decodeUnknownSync(OpenCodeEvent)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
function started(...events: OpenCodeEventEncoded[]) {
return run([delivered(root, "msg_input"), ...events])
const live = (...events: OpenCodeEventEncoded[]) => [delivered(root, "msg_input"), ...events]
const usage = (input: number, output: number, reasoning = 0, read = 0, write = 0) => ({
input,
output,
reasoning,
cache: { read, write },
})
const providerError = { type: "provider.error", message: "summary request failed" }
const compaction = {
started: (sessionID: string = root) =>
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: "msg_compact" }),
delta: (text: string, sessionID: string = root) => ephemeralEvent("session.compaction.delta", { sessionID, text }),
ended: (text: string, sessionID: string = root) =>
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text, recent: "" }),
failed: (error: { type: string; message: string }, reason: "auto" | "manual" = "manual") =>
durableEvent("session.compaction.failed", { sessionID: root, reason, error }),
}
const marker = (value: Record<string, unknown>) => ({
sessionUpdate: "session_info_update",
_meta: { "opencode/compaction": { messageId: "msg_compact", reason: "manual", ...value } },
})
const childMeta = (id: string, parentID: string, depth: number, title: string) => ({
"opencode/child-session": { id, parentID, depth, title },
})
const compactionMessage = (input: Record<string, unknown>) => ({
id: "msg_compaction",
type: "compaction",
reason: "auto",
time: { created: 1 },
...input,
})
type Row = {
readonly name: string
readonly ctx?: Partial<ACPTranslate.Context>
readonly events?: OpenCodeEventEncoded[]
readonly messages?: unknown[]
readonly expected: object
}
function updates(outputs: ReadonlyArray<ACPTranslate.Output>) {
return outputs.flatMap((output) => (output._tag === "SessionUpdate" ? [output.update] : []))
}
describe("acp turn translation", () => {
test("ignores a session's events until its own input is delivered, and other sessions' events after", () => {
const result = run([
textDelta(root, "msg_early", "early"),
delivered(root, "msg_other_input"),
const rows: Row[] = [
{
name: "ignores a session's events until its own input is delivered",
events: [textDelta(root, "msg_early", "early"), delivered(root, "msg_other_input"), succeeded(root)],
expected: { updates: [], terminal: undefined },
},
{
name: "ignores other sessions' events",
events: live(
delivered("ses_other", "msg_input"),
succeeded(root),
delivered(root, "msg_input"),
textDelta("ses_other", "msg_other", "other session"),
textDelta("ses_other", "msg_other", "other"),
textDelta(root, "msg_ok", "accepted"),
])
expect(result.terminal).toBeUndefined()
expect(updates(result.outputs)).toEqual([
{ sessionUpdate: "agent_message_chunk", messageId: "msg_ok", content: { type: "text", text: "accepted" } },
])
})
test("sums usage across steps, including a failed step, and keeps the last step for context", () => {
const result = started(
),
expected: { updates: [{ sessionUpdate: "agent_message_chunk", messageId: "msg_ok" }] },
},
{
name: "streams text and reasoning with one message ID per reasoning ordinal",
events: live(
reasoningDelta(root, "msg_a", "think"),
textDelta(root, "msg_a", "answer", 1),
reasoningDelta(root, "msg_a", "more", 1),
),
expected: {
updates: [
{ sessionUpdate: "agent_thought_chunk", messageId: "msg_a:reasoning:0", content: { text: "think" } },
{ sessionUpdate: "agent_message_chunk", messageId: "msg_a", content: { text: "answer" } },
{ sessionUpdate: "agent_thought_chunk", messageId: "msg_a:reasoning:1", content: { text: "more" } },
],
},
},
{
name: "ends a normal turn with end_turn",
events: live(stepEnded(root, "msg_1"), succeeded(root)),
expected: { response: { stopReason: "end_turn", usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } } },
},
{
name: "ends a length-limited step with max_tokens",
events: live(stepEnded(root, "msg_1", { finish: "length" }), succeeded(root)),
expected: { response: { stopReason: "max_tokens" } },
},
{
name: "ends a content-filtered step with refusal",
events: live(stepEnded(root, "msg_1", { finish: "content-filter" }), succeeded(root)),
expected: { response: { stopReason: "refusal" } },
},
{
name: "ends a content-filter failure with refusal and no error",
events: live(failed(root, { type: "provider.content-filter", message: "blocked" })),
expected: { response: { stopReason: "refusal" }, failure: undefined },
},
{
name: "ends a server-side interruption with cancelled",
events: live(durableEvent("session.execution.interrupted", { sessionID: root, reason: "shutdown" })),
expected: { terminal: "interrupted", response: { stopReason: "cancelled" } },
},
{
name: "ends an aborted execution with cancelled and no error",
events: live(failed(root, { type: "aborted", message: "interrupted" })),
expected: { response: { stopReason: "cancelled" }, failure: undefined },
},
{
name: "fails an execution auth failure as auth required",
events: live(failed(root, { type: "provider.auth", message: "missing key" })),
expected: { failure: "ACPAuthRequiredError" },
},
{
name: "fails a step auth failure as auth required even when the execution succeeds",
events: live(
durableEvent("session.step.failed", {
sessionID: root,
assistantMessageID: "msg_1",
error: { type: "provider.auth", message: "expired" },
}),
succeeded(root),
),
expected: { terminal: "succeeded", failure: "ACPAuthRequiredError" },
},
{
name: "fails other execution failures as service failures",
events: live(failed(root, { type: "provider.rate-limit", message: "slow down" })),
expected: { failure: "ACPServiceFailureError" },
},
{
name: "sums usage across steps and reports thought and cache tokens",
events: live(
stepEnded(root, "msg_1", { finish: "tool-calls", tokens: usage(100, 10, 0, 0, 50) }),
stepEnded(root, "msg_2", { tokens: usage(20, 5, 3, 150) }),
succeeded(root),
),
expected: {
response: {
usage: {
inputTokens: 120,
outputTokens: 15,
thoughtTokens: 3,
cachedReadTokens: 150,
cachedWriteTokens: 50,
totalTokens: 338,
},
},
context: usage(20, 5, 3, 150),
},
},
{
name: "counts a failed step's tokens and clears its error when the next step starts",
events: live(
durableEvent("session.step.failed", {
sessionID: root,
assistantMessageID: "msg_1",
error: { type: "provider.stream", message: "stream interrupted" },
cost: 0,
tokens: { ...tokens(), input: 40, output: 4 },
tokens: usage(40, 4),
}),
durableEvent("session.step.started", {
sessionID: root,
@@ -87,64 +197,413 @@ describe("acp turn translation", () => {
model: { providerID: "test", id: "test-model" },
started: 0,
}),
stepEnded(root, "msg_2", { finish: "length", tokens: { ...tokens(), input: 20, output: 7, reasoning: 2 } }),
)
expect(result.state.usage).toEqual({
turn: { input: 60, output: 11, reasoning: 2, cache: { read: 0, write: 0 } },
last: { input: 20, output: 7, reasoning: 2, cache: { read: 0, write: 0 } },
})
expect(ACPTranslate.failure(result.state)).toBeUndefined()
expect(ACPTranslate.response(result.state, root, "succeeded")).toEqual({
stopReason: "max_tokens",
usage: { inputTokens: 60, outputTokens: 11, thoughtTokens: 2, totalTokens: 73 },
_meta: {},
})
})
test("marks a compaction that fails before starting, and drops an end with no start", () => {
const result = started(
durableEvent("session.compaction.failed", {
sessionID: root,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
stepEnded(root, "msg_2", { tokens: usage(20, 7) }),
succeeded(root),
),
expected: {
response: { stopReason: "end_turn", usage: { inputTokens: 60, outputTokens: 11, totalTokens: 71 } },
failure: undefined,
context: usage(20, 7),
},
},
{
name: "excludes child session steps from the turn usage",
events: live(
childCreated("ses_child", root, "Explore"),
stepEnded("ses_child", "msg_child", { tokens: usage(500, 50) }),
succeeded("ses_child"),
stepEnded(root, "msg_root", { tokens: usage(20, 7) }),
succeeded(root),
),
expected: { response: { usage: { inputTokens: 20, outputTokens: 7, totalTokens: 27 } }, context: usage(20, 7) },
},
{
name: "streams a tool call from pending through progress to completion",
events: live(
toolStarted(root, "call_ok", "shell"),
toolCalled(root, "call_ok", { command: "printf done", workdir: "sub" }),
toolProgress(root, "call_ok", { phase: 1 }),
toolSucceeded(root, "call_ok", { exit: 0 }, "done"),
),
expected: {
updates: [
{ sessionUpdate: "tool_call", toolCallId: "call_ok", status: "pending", kind: "execute" },
{
sessionUpdate: "tool_call_update",
status: "in_progress",
title: "printf done",
locations: [{ path: path.resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
},
{ sessionUpdate: "tool_call_update", status: "in_progress" },
{
sessionUpdate: "tool_call_update",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { metadata: { exit: 0 } },
},
],
},
},
{
name: "fails a tool call with its partial content and error",
events: live(
toolStarted(root, "call_fail", "read"),
toolCalled(root, "call_fail", { path: "/workspace/missing.ts" }),
toolFailed(root, "call_fail", {
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
}),
durableEvent("session.compaction.ended", { sessionID: root, reason: "auto", text: "summary", recent: "" }),
)
expect(updates(result.outputs).map((update) => update._meta?.["opencode/compaction"])).toEqual([
{
status: "failed",
messageId: "msg_2",
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
},
])
expect(result.state.compactions.size).toBe(0)
})
test("projects a nested child's updates with its depth and its own parent", () => {
const result = started(
),
expected: {
updates: [
{ status: "pending" },
{ status: "in_progress" },
{
status: "failed",
kind: "read",
locations: [{ path: "/workspace/missing.ts" }],
content: [
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
],
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
},
],
},
},
{
name: "locates the file of an edit tool",
events: live(
toolStarted(root, "call_edit", "edit"),
toolCalled(root, "call_edit", { path: "/workspace/a.ts", oldString: "a", newString: "b" }),
toolSucceeded(root, "call_edit", {}, "edited"),
),
expected: {
updates: [{}, { locations: [{ path: "/workspace/a.ts" }] }, { locations: [{ path: "/workspace/a.ts" }] }],
},
},
{
name: "locates the file of a write tool",
events: live(
toolStarted(root, "call_write", "write"),
toolCalled(root, "call_write", { path: "/workspace/b.ts", content: "b" }),
toolSucceeded(root, "call_write", {}, "written"),
),
expected: {
updates: [{}, { locations: [{ path: "/workspace/b.ts" }] }, { locations: [{ path: "/workspace/b.ts" }] }],
},
},
{
name: "locates the files of a patch tool",
events: live(
toolStarted(root, "call_patch", "patch"),
toolCalled(root, "call_patch", {
patchText: "*** Begin Patch\n*** Update File: /workspace/c.ts\n@@\n-one\n+two\n*** End Patch",
}),
toolSucceeded(root, "call_patch", {}, "patched"),
),
expected: {
updates: [{}, { locations: [{ path: "/workspace/c.ts" }] }, { locations: [{ path: "/workspace/c.ts" }] }],
},
},
{
name: "projects a child's tool call onto the parent with its ID and title prefixed",
events: live(childCreated("ses_child", root, "Explore"), toolStarted("ses_child", "call_1", "read")),
expected: {
updates: [
{
sessionUpdate: "tool_call",
toolCallId: "ses_child:call_1",
title: "Explore: read",
_meta: childMeta("ses_child", root, 1, "Explore"),
},
],
children: [],
},
},
{
name: "projects a nested child with its depth and its own parent, and ignores unrelated sessions",
events: live(
childCreated("ses_child", root, "Explore"),
childCreated("ses_grandchild", "ses_child", "Deeper"),
childCreated("ses_stranger", "ses_unknown", "Unrelated"),
toolStarted("ses_grandchild", "call_1", "read"),
textDelta("ses_stranger", "msg_stranger", "ignored"),
)
expect(updates(result.outputs)).toEqual([
expect.objectContaining({
sessionUpdate: "tool_call",
toolCallId: "ses_grandchild:call_1",
title: "Deeper: read",
_meta: { "opencode/child-session": { id: "ses_grandchild", parentID: "ses_child", depth: 2, title: "Deeper" } },
),
expected: {
updates: [
{
toolCallId: "ses_grandchild:call_1",
title: "Deeper: read",
_meta: childMeta("ses_grandchild", "ses_child", 2, "Deeper"),
},
],
},
},
{
name: "routes child work to the extension when the client supports it",
ctx: { childUpdates: true },
events: live(
childCreated("ses_child", root, "Explore"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
textDelta("ses_child", "msg_child", "nested"),
failed("ses_child", { type: "tool.error", message: "boom" }),
),
expected: {
updates: [],
terminal: undefined,
children: [
{ childSessionId: "ses_child", parentSessionId: root, depth: 1, type: "status", status: "created" },
{ childSessionId: "ses_child", type: "status", status: "running" },
{ childSessionId: "ses_child", type: "update", update: { sessionUpdate: "agent_message_chunk" } },
{ childSessionId: "ses_child", type: "status", status: "failed", error: { type: "tool.error" } },
],
},
},
{
name: "fails the tools a cancelled turn left open, including a child's",
events: live(
childCreated("ses_child", root, "Explore"),
toolStarted(root, "call_root", "shell"),
toolCalled(root, "call_root", { command: "sleep 60" }),
toolProgress(root, "call_root", { pid: 1 }),
toolStarted("ses_child", "call_child", "read"),
),
expected: {
abandoned: [
{
toolCallId: "call_root",
status: "failed",
rawInput: { command: "sleep 60" },
rawOutput: { metadata: { pid: 1 }, error: "Cancelled" },
},
{ toolCallId: "ses_child:call_child", status: "failed", title: "Explore: read" },
],
},
},
{
name: "cancels the compaction a cancelled turn left open",
ctx: { compaction: true },
events: live(compaction.started()),
expected: {
abandoned: [{ sessionUpdate: "compaction_update", compactionId: "msg_compact", status: "cancelled" }],
},
},
{
name: "streams a compaction's summary and completes it with the full summary",
ctx: { compaction: true },
events: live(compaction.started(), compaction.delta("Sum"), compaction.delta("mary"), compaction.ended("Summary")),
expected: {
updates: [
{ sessionUpdate: "compaction_update", compactionId: "msg_compact", status: "in_progress" },
{ sessionUpdate: "compaction_summary_chunk", compactionId: "msg_compact", content: { text: "Sum" } },
{ sessionUpdate: "compaction_summary_chunk", compactionId: "msg_compact", content: { text: "mary" } },
{ status: "completed", summary: [{ type: "text", text: "Summary" }] },
],
},
},
{
name: "clears the summary of a compaction that completes without a readable one",
ctx: { compaction: true },
events: live(compaction.started(), compaction.ended("")),
expected: { updates: [{ status: "in_progress" }, { status: "completed", summary: null }] },
},
{
name: "fails a compaction with its error and clears the partial summary",
ctx: { compaction: true },
events: live(compaction.started(), compaction.delta("partial"), compaction.failed(providerError)),
expected: {
updates: [
{ status: "in_progress" },
{ sessionUpdate: "compaction_summary_chunk" },
{ status: "failed", summary: null, error: "summary request failed" },
],
},
},
{
name: "keeps core's own compaction failure messages",
ctx: { compaction: true },
events: live(compaction.started(), compaction.failed({ type: "compaction.failed", message: "Output limit" })),
expected: { updates: [{ status: "in_progress" }, { status: "failed", error: "Output limit" }] },
},
{
name: "reports a compaction defect as a generic failure",
ctx: { compaction: true },
events: live(
compaction.started(),
compaction.failed({ type: "compaction.failed", message: "Error: locked\n at run (compaction.ts:1:1)" }),
),
expected: { updates: [{ status: "in_progress" }, { status: "failed", error: "Compaction failed" }] },
},
{
name: "reports an interrupted compaction as cancelled",
ctx: { compaction: true },
events: live(compaction.started(), compaction.failed({ type: "compaction.interrupted", message: "interrupted" })),
expected: { updates: [{ status: "in_progress" }, { status: "cancelled", summary: null }] },
},
{
name: "reports an aborted compaction as cancelled",
ctx: { compaction: true },
events: live(compaction.started(), compaction.failed({ type: "aborted", message: "Compaction cancelled" })),
expected: { updates: [{ status: "in_progress" }, { status: "cancelled", summary: null }] },
},
{
name: "opens a compaction that fails before it starts before settling it",
ctx: { compaction: true },
events: live(compaction.failed({ type: "compaction.unavailable", message: "Nothing to compact yet" }, "auto")),
expected: {
updates: [
{ compactionId: "msg_2", status: "in_progress" },
{ compactionId: "msg_2", status: "failed", error: "Nothing to compact yet" },
],
},
},
{
name: "drops summary chunks outside an open compaction",
ctx: { compaction: true },
events: live(
compaction.delta("early"),
compaction.started(),
compaction.ended("Summary"),
compaction.delta("late"),
),
expected: { updates: [{ status: "in_progress" }, { status: "completed" }] },
},
{
name: "drops a compaction end without a start",
ctx: { compaction: true },
events: live(compaction.ended("Summary")),
expected: { updates: [] },
},
{
name: "marks a compaction without forwarding its summary when the client lacks the capability",
events: live(compaction.started(), compaction.delta("Summary"), compaction.ended("Summary")),
expected: { updates: [marker({ status: "started" }), marker({ status: "completed" })] },
},
{
name: "marks a failed compaction with the full error",
events: live(compaction.started(), compaction.failed(providerError)),
expected: { updates: [marker({ status: "started" }), marker({ status: "failed", error: providerError })] },
},
{
name: "marks a compaction that fails before it starts without opening it",
events: live(compaction.failed(providerError, "auto")),
expected: {
updates: [
{
_meta: {
"opencode/compaction": { status: "failed", messageId: "msg_2", reason: "auto", error: providerError },
},
},
],
},
},
{
name: "keeps a child's compaction projected onto the parent as a marker",
ctx: { compaction: true },
events: live(
childCreated("ses_child", root, "Explore"),
compaction.started("ses_child"),
compaction.ended("Summary", "ses_child"),
),
expected: {
updates: [
{ _meta: { "opencode/compaction": { status: "started" }, ...childMeta("ses_child", root, 1, "Explore") } },
{ _meta: { "opencode/compaction": { status: "completed" }, ...childMeta("ses_child", root, 1, "Explore") } },
],
},
},
{
name: "sends a child's compaction as standard updates through the extension",
ctx: { compaction: true, childUpdates: true },
events: live(
childCreated("ses_child", root, "Explore"),
compaction.started("ses_child"),
compaction.ended("Summary", "ses_child"),
),
expected: {
updates: [],
children: [
{ status: "created" },
{ type: "update", update: { sessionUpdate: "compaction_update", status: "in_progress" } },
{ type: "update", update: { sessionUpdate: "compaction_update", status: "completed" } },
],
},
},
{
name: "replays a completed compaction as one terminal update",
ctx: { compaction: true },
messages: [compactionMessage({ status: "completed", summary: "Summary", recent: "" })],
expected: {
updates: [{ compactionId: "msg_compaction", status: "completed", summary: [{ type: "text", text: "Summary" }] }],
},
},
{
name: "replays a compaction without a summary as cleared",
ctx: { compaction: true },
messages: [compactionMessage({ status: "completed", summary: "", recent: "" })],
expected: { updates: [{ status: "completed", summary: null }] },
},
{
name: "replays a failed compaction with its error",
ctx: { compaction: true },
messages: [compactionMessage({ status: "failed", error: providerError })],
expected: { updates: [{ status: "failed", summary: null, error: "summary request failed" }] },
},
{
name: "replays a cancelled compaction as cancelled",
ctx: { compaction: true },
messages: [compactionMessage({ status: "failed", error: { type: "aborted", message: "Compaction cancelled" } })],
expected: { updates: [{ status: "cancelled", summary: null }] },
},
{
name: "skips a running compaction on replay",
ctx: { compaction: true },
messages: [compactionMessage({ status: "running", summary: "", recent: "" })],
expected: { updates: [] },
},
{
name: "replays a compaction as a marker when the client lacks the capability",
messages: [compactionMessage({ status: "completed", summary: "Summary", recent: "" })],
expected: {
updates: [
{
sessionUpdate: "session_info_update",
_meta: { "opencode/compaction": { status: "completed", messageId: "msg_compaction", reason: "auto" } },
},
],
},
},
{
name: "replays reasoning with the same message IDs as live reasoning ordinals",
messages: [
assistantMessage("msg_a", {
content: [
{ type: "reasoning", text: "think" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "more" },
],
}),
])
expect([...result.state.openChildren]).toEqual(["ses_child", "ses_grandchild"])
],
expected: {
updates: [
{ sessionUpdate: "agent_thought_chunk", messageId: "msg_a:reasoning:0" },
{ sessionUpdate: "agent_message_chunk", messageId: "msg_a" },
{ sessionUpdate: "agent_thought_chunk", messageId: "msg_a:reasoning:1" },
],
},
},
]
describe("acp turn translation", () => {
test.each(rows)("$name", (row) => {
expect(translate(row)).toMatchObject(row.expected)
})
test("ends a background consumer when its last open child settles, without session updates", () => {
const turn = started(childCreated("ses_a", root, "A"), childCreated("ses_b", root, "B"), succeeded(root))
const turn = run(live(childCreated("ses_a", root, "A"), childCreated("ses_b", root, "B"), succeeded(root)))
const background = { ...ctx, mode: "background" as const }
const first = run(
[textDelta(root, "msg_root", "ignored"), toolStarted("ses_a", "call_1", "read"), succeeded("ses_a")],
@@ -159,30 +618,52 @@ describe("acp turn translation", () => {
expect(last.state.children.has("ses_later")).toBe(false)
expect(last.terminal).toBe("interrupted")
})
test("fails the tools a cancelled turn left open, including a child's", () => {
const result = started(
childCreated("ses_child", root, "Explore"),
toolStarted(root, "call_root", "shell"),
toolCalled(root, "call_root", { command: "sleep 60" }),
toolProgress(root, "call_root", { pid: 1 }),
toolStarted("ses_child", "call_child", "read"),
failed("ses_child", { type: "aborted", message: "interrupted" }),
)
const abandoned = ACPTranslate.abandon(result.state, ctx)
expect(abandoned.state.tools.size).toBe(0)
expect(updates(abandoned.outputs)).toEqual([
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId: "call_root",
status: "failed",
rawInput: expect.objectContaining({ command: "sleep 60" }),
rawOutput: { metadata: { pid: 1 }, error: "Cancelled" },
}),
expect.objectContaining({ toolCallId: "ses_child:call_child", status: "failed", title: "Explore: read" }),
])
expect(ACPTranslate.abandon(abandoned.state, ctx).outputs).toEqual([])
})
})
function translate(row: Row) {
const context = { ...ctx, ...row.ctx }
if (row.messages) {
const capabilities = {
childSessionUpdates: context.childUpdates,
formElicitation: false,
compaction: context.compaction,
}
return {
updates: row.messages.flatMap((message) => [
...ACPReplay.updates(decodeMessage(message), context.cwd, capabilities),
]),
}
}
const result = run(row.events ?? [], context)
return {
updates: updates(result.outputs),
children: result.outputs.flatMap((output) => (output._tag === "ChildUpdate" ? [output.update] : [])),
terminal: result.terminal,
response: result.terminal ? ACPTranslate.response(result.state, root, result.terminal) : undefined,
failure: ACPTranslate.failure(result.state)?._tag,
context: result.state.usage?.last,
abandoned: updates(ACPTranslate.abandon(result.state, context).outputs),
}
}
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx, state = ACPTranslate.initial) {
return events.reduce<{
state: ACPTranslate.TurnState
outputs: ACPTranslate.Output[]
terminal?: ACPTranslate.Terminal
}>(
(acc, event, index) => {
const next = ACPTranslate.step(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
return {
state: next.state,
outputs: [...acc.outputs, ...next.outputs],
...(next.terminal ? { terminal: next.terminal } : {}),
}
},
{ state, outputs: [] },
)
}
function updates(outputs: ReadonlyArray<ACPTranslate.Output>) {
return outputs.flatMap((output) => (output._tag === "SessionUpdate" ? [output.update] : []))
}
+105 -394
View File
@@ -1,347 +1,129 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { resolve } from "node:path"
import { ACPElicitation } from "../../src/acp/elicitation"
import {
assistantMessage,
childCreated,
delivered,
durableEvent,
ephemeralEvent,
failed,
interrupted,
permissionAsked,
reasoningDelta,
startSession,
stepEnded,
succeeded,
textDelta,
toolCalled,
toolFailed,
toolProgress,
toolStarted,
toolSucceeded,
turn,
type ChildUpdate,
} from "./wire-fixture"
const grandchild = {
"opencode/child-session": { id: "ses_grandchild", parentID: "ses_child", depth: 2, title: "Deeper" },
}
describe("acp turn events over the wire", () => {
test("isolates events from other sessions and other inputs", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
textDelta(sessionID, "msg_before", "before admission"),
delivered("ses_other", id),
delivered(sessionID, "msg_other"),
textDelta(sessionID, "msg_wrong_input", "wrong input"),
delivered(sessionID, id),
textDelta("ses_other", "msg_other", "other session"),
textDelta(sessionID, "msg_accepted", "accepted"),
stepEnded(sessionID, "msg_accepted"),
succeeded("ses_other"),
succeeded(sessionID),
],
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(turnUpdates(acp.updates)).toEqual([
{
sessionId: acp.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_accepted",
content: { type: "text", text: "accepted" },
test.each([
{
childSessionUpdates: false,
parent: [
{
sessionUpdate: "tool_call",
toolCallId: "ses_grandchild:call_read",
title: "Deeper: read",
_meta: grandchild,
},
},
])
expect(response.stopReason).toBe("end_turn")
})
test("streams ordered reasoning and text chunks before admission returns", async () => {
const releaseAdmission = Promise.withResolvers<void>()
await using acp = await startSession({ onPrompt: () => releaseAdmission.promise })
const prompt = acp.prompt(acp.sessionId, "hello")
const settled = { value: false }
void prompt.finally(() => {
settled.value = true
})
const submitted = await acp.until(() => acp.server.prompts[0], "prompt submission")
const sessionID = acp.sessionId
acp.server.send(
...turn(
sessionID,
submitted.id,
reasoningDelta(sessionID, "msg_order", "think-1"),
reasoningDelta(sessionID, "msg_order", " continued"),
textDelta(sessionID, "msg_order", "answer", 1),
reasoningDelta(sessionID, "msg_order", "think-2", 1),
stepEnded(sessionID, "msg_order"),
),
)
await acp.until(() => chunks(acp.updates).length === 4, "streamed chunks")
expect(settled.value).toBe(false)
releaseAdmission.resolve()
expect((await prompt).stopReason).toBe("end_turn")
expect(chunks(acp.updates)).toEqual([
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
])
})
test("replays reasoning parts with the same message IDs as live reasoning ordinals", async () => {
await using acp = await startSession()
acp.server.messages.set(acp.sessionId, [
assistantMessage("msg_order", {
time: { created: 1 },
content: [
{ type: "reasoning", text: "think-1 continued" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "think-2" },
],
}),
])
await acp.request("session/load", { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] })
expect(chunks(acp.updates)).toEqual([
["agent_thought_chunk", "msg_order:reasoning:0", "think-1 continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
])
})
test("projects foreground child session updates onto the parent turn without the child capability", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
toolStarted("ses_child", "call_read", "read"),
toolCalled("ses_child", "call_read", { path: "/workspace/src/index.ts" }),
toolSucceeded("ses_child", "call_read", {}, "source"),
succeeded("ses_child"),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
const updates = turnUpdates(acp.updates)
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate, toolCallID(item)])).toEqual([
[acp.sessionId, "tool_call", "ses_child:call_read"],
[acp.sessionId, "tool_call_update", "ses_child:call_read"],
[acp.sessionId, "tool_call_update", "ses_child:call_read"],
])
expect(updates[0]?.update).toMatchObject({
title: "Explore code: read",
_meta: {
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore code" },
},
})
expect(acp.childUpdates).toEqual([])
expect(response.stopReason).toBe("end_turn")
})
test("routes foreground and nested child updates to the extension when the client supports it", async () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: true },
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
childCreated("ses_grandchild", "ses_child", "Deeper"),
durableEvent("session.execution.started", { sessionID: "ses_grandchild" }),
textDelta("ses_grandchild", "msg_grandchild", "nested"),
failed("ses_grandchild", { type: "tool.error", message: "boom" }),
succeeded("ses_child"),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(turnUpdates(acp.updates)).toEqual([])
expect(acp.childUpdates).toEqual([
expect.objectContaining({ childSessionId: "ses_child", depth: 1, type: "status", status: "created" }),
expect.objectContaining({
childSessionId: "ses_grandchild",
parentSessionId: "ses_child",
rootSessionId: acp.sessionId,
depth: 2,
title: "Deeper",
type: "status",
status: "created",
}),
expect.objectContaining({ childSessionId: "ses_grandchild", type: "status", status: "running" }),
expect.objectContaining({
childSessionId: "ses_grandchild",
type: "update",
update: expect.objectContaining({ sessionUpdate: "agent_message_chunk", messageId: "msg_grandchild" }),
}),
expect.objectContaining({
childSessionId: "ses_grandchild",
type: "status",
status: "failed",
error: { type: "tool.error", message: "boom" },
}),
expect.objectContaining({ childSessionId: "ses_child", type: "status", status: "completed" }),
])
expect(response.stopReason).toBe("end_turn")
})
test("continues child extension updates after the parent turn ends", async () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: true },
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, childCreated("ses_background", sessionID, "Background research")),
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
acp.server.send(
childCreated("ses_future", acp.sessionId, "Later turn child"),
durableEvent("session.execution.started", { sessionID: "ses_future" }),
durableEvent("session.execution.started", { sessionID: "ses_background" }),
toolStarted("ses_background", "call_shell", "shell"),
toolCalled("ses_background", "call_shell", { command: "pwd" }),
toolSucceeded("ses_background", "call_shell", { exit: 0 }, "/workspace"),
succeeded("ses_background"),
)
await acp.until(
() => acp.childUpdates.some((item) => item.type === "status" && item.status === "completed"),
"background child completion",
)
expect(turnUpdates(acp.updates)).toEqual([])
expect(acp.childUpdates.map(childUpdateKind)).toEqual([
"status:created",
"status:running",
"update:tool_call",
"update:tool_call_update",
"update:tool_call_update",
"status:completed",
])
expect(acp.childUpdates[2]).toMatchObject({
rootSessionId: acp.sessionId,
childSessionId: "ses_background",
parentSessionId: acp.sessionId,
depth: 1,
title: "Background research",
type: "update",
update: { toolCallId: "ses_background:call_shell" },
})
expect(acp.childUpdates.some((item) => item.childSessionId === "ses_future")).toBe(false)
})
test("keeps following open children after a cancelled turn", async () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
childCreated("ses_background", sessionID, "Background research"),
textDelta(sessionID, "msg_root", "working"),
{ sessionUpdate: "tool_call_update", toolCallId: "ses_grandchild:call_read", status: "in_progress" },
{ sessionUpdate: "tool_call_update", toolCallId: "ses_grandchild:call_read", status: "completed" },
],
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
permission: () => ({ outcome: { outcome: "selected", optionId: "once" } }),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await prompt).stopReason).toBe("cancelled")
acp.server.send(permissionAsked("ses_background", "perm_background"), interrupted("ses_background"))
await acp.until(
() => acp.childUpdates.some((item) => item.type === "status" && item.status === "interrupted"),
"background child interruption",
)
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
expect(acp.childUpdates.map(childUpdateKind)).toEqual(["status:created", "status:interrupted"])
expect(acp.server.replies).toEqual([
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
])
})
test("stops following background children once the session closes", async () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: true },
onPrompt: ({ sessionID, id, text }) =>
text === "hello"
? turn(sessionID, id, childCreated("ses_background", sessionID, "Background research"))
: turn(sessionID, id),
})
const other = await acp.newSession()
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
await acp.request("session/close", { sessionId: acp.sessionId })
acp.server.send(textDelta("ses_background", "msg_late", "after close"))
expect((await acp.prompt(other.sessionId, "later")).stopReason).toBe("end_turn")
expect(acp.childUpdates.map(childUpdateKind)).toEqual(["status:created"])
})
test("streams tool pending, progress, success, and failure updates", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_ok", "shell"),
toolCalled(sessionID, "call_ok", { command: "printf done", workdir: "sub" }),
toolProgress(sessionID, "call_ok", { phase: 1 }),
toolSucceeded(sessionID, "call_ok", { exit: 0 }, "done"),
toolStarted(sessionID, "call_fail", "read"),
toolCalled(sessionID, "call_fail", { path: "/workspace/missing.ts" }),
toolProgress(sessionID, "call_fail", { bytes: 0 }),
toolFailed(sessionID, "call_fail", {
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
}),
stepEnded(sessionID, "msg_tools"),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
const updates = turnUpdates(acp.updates)
expect(updates.map((item) => [item.update.sessionUpdate, toolStatus(item), toolCallID(item)])).toEqual([
["tool_call", "pending", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "completed", "call_ok"],
["tool_call", "pending", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "failed", "call_fail"],
])
expect(updates[1]?.update).toMatchObject({
title: "printf done",
kind: "execute",
locations: [{ path: resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
})
expect(updates[2]?.update).not.toHaveProperty("content")
expect(updates[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[7]?.update).toMatchObject({
kind: "read",
locations: [{ path: "/workspace/missing.ts" }],
content: [
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
child: [],
},
{
childSessionUpdates: true,
parent: [],
child: [
{ childSessionId: "ses_child", depth: 1, type: "status", status: "created" },
{ childSessionId: "ses_child", type: "status", status: "running" },
{
childSessionId: "ses_grandchild",
parentSessionId: "ses_child",
depth: 2,
title: "Deeper",
status: "created",
},
{
childSessionId: "ses_grandchild",
update: { sessionUpdate: "tool_call", toolCallId: "ses_grandchild:call_read" },
},
{ childSessionId: "ses_grandchild", update: { sessionUpdate: "tool_call_update", status: "in_progress" } },
{ childSessionId: "ses_grandchild", update: { sessionUpdate: "tool_call_update", status: "completed" } },
{ childSessionId: "ses_grandchild", type: "status", status: "completed" },
{ childSessionId: "ses_background", type: "status", status: "created" },
{ childSessionId: "ses_child", type: "status", status: "completed" },
{ childSessionId: "ses_background", type: "status", status: "running" },
],
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
})
expect(response.stopReason).toBe("end_turn")
})
},
])(
"shows nested and background child work and routes its permissions (child capability: $childSessionUpdates)",
async (row) => {
await using acp = await startSession({
capabilities: { childSessionUpdates: row.childSessionUpdates },
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
childCreated("ses_grandchild", "ses_child", "Deeper"),
toolStarted("ses_grandchild", "call_read", "read"),
toolCalled("ses_grandchild", "call_read", { path: "/workspace/src/index.ts" }),
permissionAsked("ses_grandchild", "perm_child", {
action: "read",
source: { type: "tool", messageID: "msg_child", id: "call_read" },
}),
toolSucceeded("ses_grandchild", "call_read", {}, "source"),
succeeded("ses_grandchild"),
childCreated("ses_background", sessionID, "Research"),
succeeded("ses_child"),
),
permission: () => ({ outcome: { outcome: "selected", optionId: "once" } }),
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
acp.server.send(
durableEvent("session.execution.started", { sessionID: "ses_background" }),
permissionAsked("ses_background", "perm_background", {
action: "read",
metadata: { path: "/workspace/notes.md" },
}),
)
await acp.until(() => acp.server.replies.length === 2, "child permission replies")
expect(turnUpdates(acp.updates).map((item) => item.update)).toMatchObject(row.parent)
expect(acp.childUpdates).toMatchObject(row.child)
expect(acp.permissions).toMatchObject([
{
sessionId: acp.sessionId,
toolCall: {
toolCallId: "ses_grandchild:call_read",
title: "Deeper: /workspace/src/index.ts",
_meta: grandchild,
},
},
{
sessionId: acp.sessionId,
toolCall: {
toolCallId: "ses_background:perm_background",
title: "Research: /workspace/notes.md",
_meta: {
"opencode/child-session": { id: "ses_background", parentID: acp.sessionId, depth: 1, title: "Research" },
},
},
},
])
expect(acp.server.replies).toEqual([
{ sessionID: "ses_grandchild", requestID: "perm_child", decision: "once" },
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
])
},
)
test("cancels unsupported session forms so execution can continue", async () => {
await using acp = await startSession({
@@ -369,51 +151,6 @@ describe("acp turn events over the wire", () => {
])
expect(acp.elicitations).toEqual([])
})
test("reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)", async () => {
const patchText = [
"*** Begin Patch",
"*** Update File: /workspace/src/c.ts",
"@@",
"-one",
"+two",
"*** End Patch",
].join("\n")
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "/workspace/src/a.ts", oldString: "a", newString: "b" }),
toolSucceeded(sessionID, "call_edit", {}, "edited"),
toolStarted(sessionID, "call_write", "write"),
toolCalled(sessionID, "call_write", { path: "/workspace/src/b.ts", content: "b" }),
toolSucceeded(sessionID, "call_write", {}, "written"),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
toolSucceeded(sessionID, "call_patch", {}, "patched"),
),
})
await acp.prompt(acp.sessionId, "hello")
const locations = turnUpdates(acp.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update")
.map((item) => [
toolCallID(item),
toolStatus(item),
"locations" in item.update ? item.update.locations : undefined,
])
expect(locations).toEqual([
["call_edit", "in_progress", [{ path: "/workspace/src/a.ts" }]],
["call_edit", "completed", [{ path: "/workspace/src/a.ts" }]],
["call_write", "in_progress", [{ path: "/workspace/src/b.ts" }]],
["call_write", "completed", [{ path: "/workspace/src/b.ts" }]],
["call_patch", "in_progress", [{ path: "/workspace/src/c.ts" }]],
["call_patch", "completed", [{ path: "/workspace/src/c.ts" }]],
])
})
})
function turnUpdates(updates: readonly SessionNotification[]) {
@@ -421,29 +158,3 @@ function turnUpdates(updates: readonly SessionNotification[]) {
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
)
}
function chunks(updates: readonly SessionNotification[]) {
return updates.flatMap((item) =>
item.update.sessionUpdate === "agent_message_chunk" || item.update.sessionUpdate === "agent_thought_chunk"
? [
[
item.update.sessionUpdate,
item.update.messageId,
item.update.content.type === "text" ? item.update.content.text : undefined,
],
]
: [],
)
}
function childUpdateKind(item: ChildUpdate) {
return item.type === "status" ? `status:${item.status}` : `update:${item.update.sessionUpdate}`
}
function toolCallID(item: SessionNotification) {
return "toolCallId" in item.update ? item.update.toolCallId : undefined
}
function toolStatus(item: SessionNotification) {
return "status" in item.update ? item.update.status : undefined
}
+25 -78
View File
@@ -16,7 +16,6 @@ import {
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type WriteTextFileRequest,
} from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode/client/effect"
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
@@ -49,10 +48,10 @@ type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
type EventData<Type extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { type: Type }>["data"]
type AssistantMessage = Extract<SessionMessageInfo, { type: "assistant" }>
export type Events = ReadonlyArray<OpenCodeEvent> | void
type Events = ReadonlyArray<OpenCodeEvent> | void
type Hook<Input> = (input: Input) => Events | Promise<Events>
export type ServerRequest = {
type ServerRequest = {
readonly method: string
readonly path: string
readonly query: Record<string, string>
@@ -89,7 +88,7 @@ const FormReplyBody = Schema.Struct({ answer: Form.Answer })
const McpBody = Schema.Struct({ config: Schema.Unknown })
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
export type Submission =
type Submission =
| ({ readonly kind: "prompt"; readonly sessionID: string } & typeof PromptBody.Type)
| ({ readonly kind: "command"; readonly sessionID: string } & typeof CommandBody.Type)
| ({ readonly kind: "compact"; readonly sessionID: string } & typeof CompactBody.Type)
@@ -97,7 +96,7 @@ export type Submission =
type PromptSubmission = Extract<Submission, { readonly kind: "prompt" }>
export type Selection =
type Selection =
| { readonly sessionID: string; readonly model: typeof ModelBody.Type.model }
| { readonly sessionID: string; readonly agent: string }
@@ -123,7 +122,6 @@ const ChildUpdate = Schema.Union([
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
}),
])
export type ChildUpdate = typeof ChildUpdate.Type
export type WireOptions = {
readonly fetch?: (request: ServerRequest) => Response | undefined | Promise<Response | undefined>
@@ -134,11 +132,6 @@ export type WireOptions = {
readonly signal: AbortSignal
}>
readonly onInterrupt?: Hook<{ readonly sessionID: string }>
readonly onPermissionReply?: Hook<{
readonly sessionID: string
readonly requestID: string
readonly decision: string
}>
readonly onFormCancel?: Hook<{ readonly sessionID: string; readonly formID: string }>
readonly onFormReply?: Hook<FormReply>
readonly permission?: (
@@ -158,25 +151,20 @@ type FormReply = {
readonly answer: typeof FormReplyBody.Type.answer
}
type CatalogKind = "model" | "default" | "agent" | "command"
export type Catalog = {
type Catalog = {
models: ModelInfo[]
// Unset follows the first listed model.
defaultModel?: ModelInfo | null
agents: AgentInfo[]
commands: CommandInfo[]
}
export type InitializeOptions = {
readonly writeTextFile?: boolean
readonly childSessionUpdates?: boolean
readonly terminalAuth?: boolean
readonly elicitation?: boolean
readonly compaction?: CompactionCapabilities | null
}
export const testModel = {
const testModel = {
id: "test-model",
modelID: "test-model",
providerID: "test",
@@ -204,7 +192,7 @@ export const secondModel = {
limit: { context: 200_000, output: 20_000 },
} satisfies ModelInfo
export const buildAgent = {
const buildAgent = {
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
@@ -213,7 +201,7 @@ export const buildAgent = {
permissions: [],
} satisfies AgentInfo
export const planAgent = {
const planAgent = {
id: "plan",
name: "Plan",
description: "Plan first",
@@ -404,10 +392,6 @@ export function enqueued(sessionID: string, id: string, type: string, payload: o
return Response.json({ data: { id, sessionID, time: { created: 0 }, type, payload, delivery: "steer" } })
}
export function makeClient(baseUrl: string) {
return OpenCode.make({ baseUrl }).pipe(Effect.provide(FetchHttpClient.layer))
}
export async function startWire(options: WireOptions = {}) {
const waiters = new Set<() => void>()
const changed = () => waiters.forEach((check) => check())
@@ -416,8 +400,7 @@ export async function startWire(options: WireOptions = {}) {
const received: AnyMessage[] = []
const updates: SessionNotification[] = []
const permissions: RequestPermissionRequest[] = []
const writes: WriteTextFileRequest[] = []
const childUpdates: ChildUpdate[] = []
const childUpdates: Array<typeof ChildUpdate.Type> = []
const elicitations: CreateElicitationRequest[] = []
// Client handlers record SDK-validated params; responses wait until they have seen every earlier agent message.
const counts = { sent: 0, handled: 0 }
@@ -432,7 +415,8 @@ export async function startWire(options: WireOptions = {}) {
const logs: Array<Pick<Logger.Options<unknown>, "message" | "cause">> = []
const agentScope = Scope.makeUnsafe()
const agentConnection = await Effect.runPromise(
makeClient(server.url).pipe(
OpenCode.make({ baseUrl: server.url }).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.flatMap((client) => ACP.connect(client, ndJsonStream(agentToClient.writable, clientToAgent.readable))),
Scope.provide(agentScope),
(effect) =>
@@ -456,10 +440,6 @@ export async function startWire(options: WireOptions = {}) {
handled(elicitations, ctx.params)
return options.elicitation?.(ctx.params, ctx.signal) ?? { action: "cancel" }
})
.onRequest("fs/write_text_file", (ctx) => {
handled(writes, ctx.params)
return {}
})
.connect({
writable: clientStream.writable,
readable: clientStream.readable.pipeThrough(
@@ -510,7 +490,6 @@ export async function startWire(options: WireOptions = {}) {
request("initialize", {
protocolVersion: 1,
clientCapabilities: {
...(capabilities.writeTextFile ? { fs: { writeTextFile: true, readTextFile: false } } : {}),
...(capabilities.elicitation ? { elicitation: { form: {} } } : {}),
...(capabilities.compaction !== undefined ? { session: { compaction: capabilities.compaction } } : {}),
_meta: {
@@ -523,13 +502,12 @@ export async function startWire(options: WireOptions = {}) {
return {
server,
logs,
received,
updates,
permissions,
writes,
childUpdates,
elicitations,
logs,
request,
until,
initialize,
@@ -559,7 +537,7 @@ export async function startSession(options: WireOptions & { readonly capabilitie
const wire = await startWire(options)
await wire.initialize(options.capabilities)
const session = await wire.newSession()
return Object.assign(wire, { sessionId: session.sessionId, session })
return Object.assign(wire, { sessionId: session.sessionId })
}
export async function rpcError(promise: Promise<unknown>) {
@@ -582,7 +560,6 @@ function startServer(options: WireOptions, changed: () => void) {
agents: [buildAgent, planAgent],
commands: [reviewCommand],
}
const catalogReads: Array<{ readonly kind: CatalogKind; readonly directory: string }> = []
const requests: ServerRequest[] = []
const submissions: Submission[] = []
const selections: Selection[] = []
@@ -594,7 +571,6 @@ function startServer(options: WireOptions, changed: () => void) {
const fake = {
requests,
catalog,
catalogReads,
sessions: new Map<string, SessionInfo>(),
messages: new Map<string, SessionMessageInfo[]>(),
submissions,
@@ -634,15 +610,6 @@ function startServer(options: WireOptions, changed: () => void) {
const notFound = (sessionID: string) =>
Response.json({ _tag: "SessionNotFoundError", sessionID, message: "session not found" }, { status: 404 })
const noContent = () => new Response(null, { status: 204 })
// Routes without session-location middleware reject a malformed ID in their path decode; routes with it name the field.
const pathDecodeError = {
_tag: "InvalidRequestError",
message: 'Expected a string starting with "ses"',
kind: "Params",
}
const fieldError = { _tag: "InvalidRequestError", message: "Invalid session ID", field: "sessionID" }
const malformed = (sessionID: string, body: object) =>
sessionID.startsWith("ses") ? undefined : Response.json(body, { status: 400 })
// Handlers record facts synchronously before awaiting hooks, so waiters can observe a held request.
const observed = (response: Response | Promise<Response>) => {
@@ -686,17 +653,10 @@ function startServer(options: WireOptions, changed: () => void) {
})
.finally(changed)
}
const catalogRoute = (kind: CatalogKind) =>
const catalogRoute = (data: () => unknown) =>
route((_req, query) => {
const directory = query["location[directory]"] ?? "/workspace"
fake.catalogReads.push({ kind, directory })
const data = {
model: catalog.models,
default: catalog.defaultModel === undefined ? (catalog.models[0] ?? null) : catalog.defaultModel,
agent: catalog.agents,
command: catalog.commands,
}[kind]
return Response.json({ location: { directory, project: { id: "global", directory } }, data })
return Response.json({ location: { directory, project: { id: "global", directory } }, data: data() })
})
const page = <Item>(items: readonly Item[], query: Record<string, string>, limit: number) => {
const start = Number(query.cursor ?? 0)
@@ -733,10 +693,10 @@ function startServer(options: WireOptions, changed: () => void) {
)
}),
},
"/api/model": { GET: catalogRoute("model") },
"/api/model/default": { GET: catalogRoute("default") },
"/api/agent": { GET: catalogRoute("agent") },
"/api/command": { GET: catalogRoute("command") },
"/api/model": { GET: catalogRoute(() => catalog.models) },
"/api/model/default": { GET: catalogRoute(() => catalog.models[0] ?? null) },
"/api/agent": { GET: catalogRoute(() => catalog.agents) },
"/api/command": { GET: catalogRoute(() => catalog.commands) },
"/api/session": {
GET: route((_req, query) => {
const sessions = [...fake.sessions.values()]
@@ -756,16 +716,9 @@ function startServer(options: WireOptions, changed: () => void) {
},
"/api/session/:sessionID": {
GET: route((req) => {
const invalid = malformed(req.params.sessionID, pathDecodeError)
if (invalid) return invalid
const session = fake.sessions.get(req.params.sessionID)
return session ? Response.json({ data: session }) : notFound(req.params.sessionID)
}),
DELETE: route((req) => {
const invalid = malformed(req.params.sessionID, pathDecodeError)
if (invalid) return invalid
return fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID)
}),
PATCH: body(UpdateBody, (req, input) => {
const session = fake.sessions.get(req.params.sessionID)
if (!session) return notFound(req.params.sessionID)
@@ -779,8 +732,6 @@ function startServer(options: WireOptions, changed: () => void) {
},
"/api/session/:sessionID/fork": {
POST: route((req) => {
const invalid = malformed(req.params.sessionID, fieldError)
if (invalid) return invalid
const source = fake.sessions.get(req.params.sessionID)
if (!source) return notFound(req.params.sessionID)
const forked = createSession(source)
@@ -803,12 +754,6 @@ function startServer(options: WireOptions, changed: () => void) {
"/api/session/:sessionID/message": {
GET: route((req, query) => Response.json(page(fake.messages.get(req.params.sessionID) ?? [], query, 200))),
},
"/api/session/:sessionID/message/:messageID": {
GET: route((req) => {
const message = fake.messages.get(req.params.sessionID)?.find((item) => item.id === req.params.messageID)
return message ? Response.json({ data: message }) : new Response(null, { status: 404 })
}),
},
"/api/session/:sessionID/prompt": {
POST: body(PromptBody, async (req, input) => {
const sessionID = req.params.sessionID
@@ -846,10 +791,12 @@ function startServer(options: WireOptions, changed: () => void) {
}),
},
"/api/session/:sessionID/permission/:requestID/reply": {
POST: body(ReplyBody, async (req, input) => {
const reply = { sessionID: req.params.sessionID, requestID: req.params.requestID, decision: input.decision }
fake.replies.push(reply)
await emit(options.onPermissionReply?.(reply))
POST: body(ReplyBody, (req, input) => {
fake.replies.push({
sessionID: req.params.sessionID,
requestID: req.params.requestID,
decision: input.decision,
})
return noContent()
}),
},
+2 -1
View File
@@ -5,7 +5,8 @@
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"noUncheckedIndexedAccess": false
"noUncheckedIndexedAccess": false,
"noUnusedLocals": true
},
"exclude": ["dist", "dist-node"],
"references": [{ "path": "../core" }]
+8 -5
View File
@@ -145,11 +145,14 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const info = yield* read(options.file)
if (options.pty === "handoff" && info !== undefined)
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout),
)
else yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
// Terminal handoff is best-effort; it must never keep the old service running.
yield* Effect.tryPromise(() =>
options.pty === "handoff" && info !== undefined
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
: PtyHandoff.clear(options.file ?? fallback()),
).pipe(
Effect.catch((cause) => Effect.logWarning("Failed to prepare persistent terminals for replacement", cause)),
)
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
+51 -45
View File
@@ -449,7 +449,7 @@ export function make(options: ClientOptions) {
path: `/api/location`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -474,7 +474,7 @@ export function make(options: ClientOptions) {
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -500,7 +500,7 @@ export function make(options: ClientOptions) {
path: `/api/plugin`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -513,7 +513,7 @@ export function make(options: ClientOptions) {
query: { location: input?.["location"] },
body: { target: input?.["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -526,7 +526,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { targets: input["targets"] },
successStatus: 204,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: true,
},
requestOptions,
@@ -1116,7 +1116,7 @@ export function make(options: ClientOptions) {
path: `/api/model`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -1128,7 +1128,7 @@ export function make(options: ClientOptions) {
path: `/api/model/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -1142,7 +1142,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/generate`,
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -1156,7 +1156,7 @@ export function make(options: ClientOptions) {
path: `/api/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -1182,7 +1182,7 @@ export function make(options: ClientOptions) {
path: `/api/integration`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1208,7 +1208,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { url: input["url"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1238,7 +1238,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1275,7 +1275,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1314,7 +1314,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1329,7 +1329,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1342,7 +1342,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { config: input["config"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1391,7 +1391,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp/resource`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1460,7 +1460,7 @@ export function make(options: ClientOptions) {
project: {
list: (requestOptions?: RequestOptions) =>
request<ProjectListOutput>(
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401, 404], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
@@ -1489,7 +1489,7 @@ export function make(options: ClientOptions) {
path: `/api/form`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1504,7 +1504,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1518,7 +1518,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1529,7 +1529,7 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1611,7 +1611,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/list`,
query: { location: input?.["location"], path: input?.["path"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1623,7 +1623,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/find`,
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1636,7 +1636,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"], path: input["path"] },
body: input["payload"],
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
binaryBody: true,
},
@@ -1651,7 +1651,7 @@ export function make(options: ClientOptions) {
path: `/api/command`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1665,7 +1665,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1680,7 +1680,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { input: input["input"] },
successStatus: 200,
declaredStatuses: [400, 401, 500],
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
@@ -1701,7 +1701,7 @@ export function make(options: ClientOptions) {
path: `/api/pty`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1720,7 +1720,7 @@ export function make(options: ClientOptions) {
env: input?.["env"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1911,7 +1911,7 @@ export function make(options: ClientOptions) {
path: `/api/shell`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1929,7 +1929,7 @@ export function make(options: ClientOptions) {
metadata: input["metadata"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1965,7 +1965,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1979,7 +1979,7 @@ export function make(options: ClientOptions) {
path: `/api/reference`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -2049,7 +2049,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/init`,
query: { location: input?.["location"], provider: input?.["provider"] },
successStatus: 204,
declaredStatuses: [400, 401, 409, 501, 503],
declaredStatuses: [400, 401, 404, 409, 501, 503],
empty: true,
},
requestOptions,
@@ -2061,7 +2061,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -2073,7 +2073,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/base`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -2085,7 +2085,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/status`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -2098,7 +2098,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/branch`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -2111,7 +2111,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/diff`,
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -2167,7 +2167,7 @@ export function make(options: ClientOptions) {
path: `/api/websearch/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -2180,7 +2180,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { query: input["query"], providerID: input["providerID"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -2194,14 +2194,20 @@ export function make(options: ClientOptions) {
path: `/api/config`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
shells: (requestOptions?: RequestOptions) =>
request<ConfigShellsOutput>(
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
{
method: "GET",
path: `/api/config/shell`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
update: (input: ConfigUpdateInput, requestOptions?: RequestOptions) =>
@@ -2211,7 +2217,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/config`,
body: { shell: input["shell"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -2531,6 +2531,14 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type LocationNotFoundError = {
readonly _tag: "LocationNotFoundError"
readonly location: { readonly directory: string }
readonly message: string
}
export const isLocationNotFoundError = (value: unknown): value is LocationNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
+6 -3
View File
@@ -118,9 +118,12 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const info = await read(options.file)
if (options.pty === "handoff" && info !== undefined)
await PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
else await PtyHandoff.clear(options.file ?? fallback())
// Terminal handoff is best-effort; it must never keep the old service running.
await (
options.pty === "handoff" && info !== undefined
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
: PtyHandoff.clear(options.file ?? fallback())
).catch((cause: unknown) => console.warn("Failed to prepare persistent terminals for replacement", cause))
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
+24 -34
View File
@@ -10,11 +10,14 @@ type Sidecar = {
readonly expiresAt: number
}
/** Publish the ticket before stopping its owner so every replacement contender can adopt it. */
/**
* Publish the ticket before stopping its owner so every replacement contender can adopt it.
* Never throws: terminals are best-effort, so any failure replaces the service without a handoff.
*/
export async function prepare(file: string, info: Info, timeout: number) {
const existing = await read(file)
if (existing !== undefined && existing.expiresAt > Date.now() && same(existing.source, info)) return
const { ClientError, OpenCode } = await import("./promise/index.js")
const { OpenCode } = await import("./promise/index.js")
const client = OpenCode.make({
baseUrl: info.url,
headers:
@@ -22,43 +25,30 @@ export async function prepare(file: string, info: Info, timeout: number) {
? undefined
: { authorization: "Basic " + Buffer.from(`opencode:${info.password}`).toString("base64") },
})
const missing = (error: unknown) =>
error instanceof ClientError &&
error.reason === "UnexpectedStatus" &&
typeof error.cause === "object" &&
error.cause !== null &&
"status" in error.cause &&
error.cause.status === 404
const result = await client.experimental.persistentPty.handoff({ signal: AbortSignal.timeout(timeout) }).then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
const handoff = await client.experimental.persistentPty
.handoff({ signal: AbortSignal.timeout(timeout) })
.then(parse)
.catch(async (cause: unknown) => {
// Another caller may already have prepared and stopped this server.
const concurrent = await read(file)
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info)) return
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement", cause)
await client.experimental.persistentPty.shutdown({ signal: AbortSignal.timeout(timeout) }).catch(() => {})
return null
})
if (handoff === undefined) return
await publish(file, info, handoff).catch((cause: unknown) =>
console.warn("Failed to publish persistent terminal handoff", cause),
)
if ("cause" in result) {
// Another caller may already have prepared and stopped this server.
const concurrent = await read(file)
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info)) return
if (!missing(result.cause))
throw new Error("Failed to prepare persistent terminals for service replacement", { cause: result.cause })
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement")
await client.experimental.persistentPty
.shutdown({ signal: AbortSignal.timeout(timeout) })
.catch((cause: unknown) => {
if (missing(cause)) return
throw new Error("Failed to shut down persistent terminals before service replacement", { cause })
})
await publish(file, info, null)
return
}
const body: unknown = result.value
}
function parse(body: unknown) {
if (typeof body !== "object" || body === null || !("handoff" in body))
throw new Error("Invalid persistent terminal handoff response")
if (body.handoff === null) {
await publish(file, info, null)
return
}
if (body.handoff === null) return null
if (!isHandoff(body.handoff) || body.handoff.expiresAt <= Date.now())
throw new Error("Invalid or expired persistent terminal handoff")
await publish(file, info, body.handoff)
return body.handoff
}
async function publish(file: string, info: Info, handoff: PersistentPty.Handoff | null) {
+2
View File
@@ -52,6 +52,8 @@ const server = Bun.serve({
await writeFile(registration + ".prepared", JSON.stringify(handoff))
return Response.json({ handoff })
}
if (pathname === "/api/experimental/persistent-pty/handoff" && mode === "handoff-broken")
return new Response(null, { status: 500 })
if (pathname !== "/api/info") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
@@ -147,6 +147,19 @@ test("evicts an unresponsive registered service before starting its replacement"
expect(endpoint.url).toBe(replacement.url)
})
test("stops the registered service even when terminal handoff fails", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
fixture.spawn("handoff-broken")
await fixture.waitForFile()
await Service.stop({ file: registration, pty: "handoff" })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(registration).exists()).toBe(false)
expect((await Bun.file(registration + ".pty-handoff").json()).handoff).toBeNull()
})
test("signals the registered service process", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": false,
"allowJs": false,
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"]
+1
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"noUnusedLocals": true,
"target": "ESNext",
"module": "ESNext",
"skipLibCheck": true,
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
+2 -2
View File
@@ -106,7 +106,7 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/pty": "0.2.0",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
@@ -116,7 +116,7 @@
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"bun-pty": "0.4.8",
"bun-pty": "0.4.9",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
+1 -1
View File
@@ -65,7 +65,7 @@ const description = [
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.allSettled`.",
].join("\n")
export const create = (
+21 -17
View File
@@ -10,6 +10,7 @@ import { ConfigCompaction } from "@opencode/schema/config/compaction"
import { ConfigFormatter } from "@opencode/schema/config/formatter"
import { ConfigLSP } from "@opencode/schema/config/lsp"
import { ConfigMedia } from "@opencode/schema/config/media"
import { ConfigModel } from "@opencode/schema/config/model"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { ConfigPlugin } from "@opencode/schema/config/plugin"
import { ConfigPolicy } from "@opencode/schema/config/policy"
@@ -18,13 +19,11 @@ import { ConfigReference } from "@opencode/schema/config/reference"
import { ConfigExperimental } from "@opencode/schema/config/experimental"
import { Permission } from "@opencode/schema/permission"
import { ConfigAgentV1 } from "../v1/config/agent.js"
import { ConfigAttachmentV1 } from "../v1/config/attachment.js"
import { ConfigCommandV1 } from "../v1/config/command.js"
import { ConfigMCPV1 } from "../v1/config/mcp.js"
import { ConfigPermissionV1 } from "../v1/config/permission.js"
import { ConfigPluginV1 } from "../v1/config/plugin.js"
import { ConfigProviderV1 } from "../v1/config/provider.js"
import { ConfigV1 } from "../v1/config/config.js"
import { ConfigMigrateV1 } from "../v1/config/migrate.js"
import { PositiveInt } from "../schema.js"
@@ -53,6 +52,7 @@ const unsupportedExperimental = [
] as const
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
const LegacyAutoupdate = Schema.Literals([true, false, "notify"])
export function normalize(input: unknown): Result {
if (!isRecord(input))
@@ -71,7 +71,7 @@ export function normalize(input: unknown): Result {
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
: undefined
const legacyUpdate = own(input, "autoupdate")
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
? decodeValue(LegacyAutoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const nativeUpdate = own(input, "update")
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
@@ -82,15 +82,18 @@ export function normalize(input: unknown): Result {
: undefined
: undefined
const legacyMedia = own(input, "attachment")
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
? decodeEncoded(ConfigMedia.Info, input.attachment, ["attachment"], diagnostics)
: undefined
if (legacyMedia !== undefined) {
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacyMedia !== undefined) encoded.media = legacyMedia
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
const migratedUpdate =
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
legacyUpdate === false
? "disable"
: legacyUpdate === "notify"
? "notify"
: legacyUpdate === true
? "auto"
: undefined
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
if (update !== undefined) encoded.update = update
if (legacyShare !== undefined) encoded.share = legacyShare
@@ -114,10 +117,9 @@ export function normalize(input: unknown): Result {
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
diagnoseSelectionMap(input.command, ["command"], diagnostics)
const migratedCommands = mapValues(legacyCommands, (value) => {
const migrated = ConfigMigrateV1.commands({ value })?.value
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
})
const migratedCommands = mapValues(legacyCommands, (value) =>
canonical(ConfigCommand.Info, ConfigMigrateV1.migrateCommand(value)),
)
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
mergeMap(
encoded,
@@ -135,9 +137,7 @@ export function normalize(input: unknown): Result {
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
: undefined
const migratedSmallModel = legacySmallModel
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
: undefined
const migratedSmallModel = ConfigMigrateV1.modelSelection(legacySmallModel)
if (legacySmallModel && !migratedSmallModel)
diagnostics.push({
kind: "unsupported",
@@ -197,10 +197,14 @@ export function normalize(input: unknown): Result {
normalizeFormatter(input, encoded, diagnostics)
normalizeLsp(input, encoded, diagnostics)
const model = own(input, "model")
? decodeValue(ConfigModel.Selection, input.model, ["model"], diagnostics)
: undefined
if (model) encoded.model = { ...model, providerID: ConfigMigrateV1.providerID(model.providerID) }
const nativeAtomic = {
$schema: Info.fields.$schema,
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
-48
View File
@@ -1,48 +0,0 @@
import { create } from "@opencode/schema/identifier"
const prefixes = {
job: "job",
event: "evt",
session: "ses",
message: "msg",
permission: "per",
question: "que",
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
} as const
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "descending", given)
}
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) {
return createID(prefixes[prefix], direction)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function createID(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
return prefix + "_" + create(direction === "descending", timestamp)
}
export { createID as create }
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
return Number(BigInt(`0x${hex}`) / 0x1000n)
}
export * as Identifier from "./id.js"
+14 -4
View File
@@ -114,7 +114,7 @@ const nodes = [
export const graph = LayerNode.group(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = Layer.Error<ReturnType<typeof layer>>
export type Error = FileSystem.DirectoryNotFoundError
export interface Options {
// Plugins this instance is born with; empty and absent are equivalent.
@@ -145,7 +145,7 @@ const vanillaReplacements: LayerNode.Replacements = [
]
// One instance is one compiled, fresh copy of the graph standing on a directory.
export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Services> {
export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Services, Error> {
const startedAt = performance.now()
// Ordered: vanilla defaults, then caller replacements (which win over the
// defaults), then instance bindings (which win over everything).
@@ -157,8 +157,18 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
]
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
// Instance boot failures are defects; provided operations retain their typed errors.
Layer.orDie,
// A missing directory is expected; other instance boot failures remain defects.
Layer.catchCause(
(cause): Layer.Layer<Services, Error> =>
Layer.unwrap(
Effect.failCause(cause).pipe(
Effect.catch(
(error): Effect.Effect<never, Error> =>
error instanceof FileSystem.DirectoryNotFoundError ? Effect.fail(error) : Effect.die(error),
),
),
),
),
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
Layer.tap(() =>
Effect.logInfo("location services booted", {
+2 -2
View File
@@ -5,13 +5,13 @@ import { Context, type Effect } from "effect"
import type { Session } from "@opencode/schema/session"
import { Node } from "@opencode/util/effect/app-node"
import { LayerNode } from "@opencode/util/effect/layer-node"
import type { Services } from "../instance.js"
import type { Services, Error } from "../instance.js"
/** Selects Session capabilities; implementations own caching and lifetime. */
export interface Interface {
readonly provide: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Services>>
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | Error, Exclude<R, Services>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instance") {}
+5 -7
View File
@@ -2,7 +2,6 @@ export * as Job from "./job.js"
import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, Scope, SynchronizedRef } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Identifier } from "./id/id.js"
import { KV } from "./kv.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
@@ -94,7 +93,7 @@ type BlockStart =
| { type: "wait"; wait: BlockWait }
export type StartInput = {
id?: string
id: string
type: string
title?: string
metadata?: Record<string, unknown>
@@ -253,21 +252,20 @@ export const make = Effect.gen(function* () {
const start: Interface["start"] = Effect.fnUntraced(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job")
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
const backgrounded = yield* Deferred.make<Info>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [StartResult, Map<string, Active>]> {
const existing = jobs.get(id)
const existing = jobs.get(input.id)
if (existing?.info.status === "running") {
return [{ info: snapshot(existing) }, jobs]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const job = {
info: {
id,
id: input.id,
type: input.type,
title: input.title,
status: "running" as const,
@@ -283,13 +281,13 @@ export const make = Effect.gen(function* () {
consumed: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
return [{ info: snapshot(job), scope }, new Map(jobs).set(input.id, job)]
}),
)
if ("scope" in result)
yield* restore(input.run).pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, result.scope, exit)),
Effect.flatMap((exit) => settle(input.id, result.scope, exit)),
Effect.asVoid,
Effect.forkIn(result.scope, { startImmediately: true }),
)
+1 -1
View File
@@ -17,7 +17,7 @@ export function buildLocationServiceMap(
Effect.gen(function* () {
const owner = yield* Effect.scope
const builds = MutableHashMap.empty<Location.Ref, { close?: Effect.Effect<void> }>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
const inner: LayerMap.LayerMap<Location.Ref, LocationServices, Instance.Error> = yield* LayerMap.make(
(ref: Location.Ref) => {
const build: { close?: Effect.Effect<void> } = {}
MutableHashMap.set(builds, ref, build)
+49 -18
View File
@@ -26,7 +26,7 @@ import {
type Transport,
type VersionNegotiationOptions,
} from "@modelcontextprotocol/client"
import { Cause, Effect, Exit, Schema } from "effect"
import { Cause, Clock, Duration, Effect, Exit, Schedule, Schema } from "effect"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import type { Session } from "@opencode/schema/session"
import { McpStdio } from "./stdio.js"
@@ -49,6 +49,14 @@ const describe = (error: unknown) => {
return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message
}
// Overloaded or restarting servers and dropped connections usually recover within a second.
// Fetch reports network failures as a TypeError carrying a system error code.
// 501 means the server will never support the request.
const isTransient = (error: unknown) =>
error instanceof SdkHttpError
? error.status === 408 || error.status === 429 || (error.status >= 500 && error.status !== 501)
: error instanceof TypeError && "code" in error
export type { GetPromptResult, Prompt, ReadResourceResult, Resource, Tool }
export type ResourceTemplate = ResourceTemplateType
@@ -159,6 +167,20 @@ export const connect = Effect.fnUntraced(function* (
? `${describe(error)}: ${lastError}`
: describe(error)
// Two more attempts, 250 ms and 1 s later, while the failure is transient and the deadline has not passed.
const retrying = <A, E>(what: string, effect: Effect.Effect<A, E>, deadline = Infinity) =>
Effect.retry(
effect,
Schedule.max([Schedule.exponential("250 millis", 4), Schedule.recurs(2)]).pipe(
Schedule.setInputType<E>(),
Schedule.while(({ input, now, duration }) =>
!isTransient(input) || now + Duration.toMillis(duration) >= deadline
? Effect.succeed(false)
: Effect.logWarning(`retrying ${what}`, { server, error: describe(input) }).pipe(Effect.as(true)),
),
),
)
const initialize = Effect.fnUntraced(function* (transport: Transport) {
const runFork = Effect.runForkWith(yield* Effect.context())
const client = new Client(clientInfo, {
@@ -245,22 +267,28 @@ export const connect = Effect.fnUntraced(function* (
const url = new URL(config.url)
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
if (addedCodemode) url.searchParams.set("codemode", "false")
const open = (url: URL) => {
const open = Effect.fnUntraced(function* (url: URL) {
session.transport = new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
fetch,
})
return initialize(session.transport)
}
return yield* initialize(session.transport)
})
return yield* open(url).pipe(
Effect.catch((error) => {
if (!addedCodemode || !(error instanceof SdkHttpError) || (error.status !== 400 && error.status !== 404))
return Effect.fail(error)
// Servers that reject unknown query params get one retry at the configured URL.
return open(new URL(config.url))
}),
// Every attempt opens a fresh transport; no retry starts once the startup timeout has passed.
const deadline = (yield* Clock.currentTimeMillis) + (config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT)
return yield* retrying(
"MCP connect",
open(url).pipe(
Effect.catch((error) => {
if (!addedCodemode || !(error instanceof SdkHttpError) || (error.status !== 400 && error.status !== 404))
return Effect.fail(error)
// Servers that reject unknown query params get one retry at the configured URL.
return open(new URL(config.url))
}),
),
deadline,
)
}).pipe(Effect.exit)
if (Exit.isSuccess(exit)) {
@@ -292,20 +320,23 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({ try: run, catch: failure }).pipe(
Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
)
// Listing is read-only, so a transient failure is safe to retry.
const list = <A>(what: string, run: () => Promise<A>) =>
retrying(what, Effect.tryPromise({ try: run, catch: (error) => error })).pipe(
Effect.mapError(failure),
Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
)
return {
modern: client.getProtocolEra() === "modern",
instructions: client.getInstructions()?.trim() || undefined,
tools: () =>
request("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
tools: () => list("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
prompts: () =>
request("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
list("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
resources: () =>
request("list MCP resources", () => client.listResources(undefined, catalog)).pipe(
Effect.map((r) => r.resources),
),
list("list MCP resources", () => client.listResources(undefined, catalog)).pipe(Effect.map((r) => r.resources)),
resourceTemplates: () =>
request("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
list("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
Effect.map((r) => r.resourceTemplates),
),
readResource: (input) => {
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more