Compare commits

...
Author SHA1 Message Date
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
Jack 35a41b5d53 docs(www): add Ling 3.1 Flash Free to Console models (#52780) 2026-10-03 00:08:55 +08:00
Aiden Cline d49ec44298 feat(ai): add native Cohere chat provider (#52633) 2026-10-02 11:07:02 -05:00
Shoubhit Dash 7440ff7844 chore(acp): trim comments (#52770) 2026-10-02 20:50:09 +05:30
Shoubhit Dash f902a0fae7 fix(cli): support Homebrew Core upgrades (#52753) 2026-10-02 20:27:14 +05:30
Filip 661853903b fix(tui): gate terminals on server persistent PTY support (#52760) 2026-10-02 16:49:15 +02:00
Shoubhit Dash 06b6c916a5 refactor(acp): drop the promise client (#52751) 2026-10-02 19:52:02 +05:30
Shoubhit Dash bf25bd2ec7 docs(acp): document opencode protocol extensions (#52745) 2026-10-02 19:50:17 +05:30
Filip 3ddb0cb1d3 docs: remove skill slash frontmatter from v2 docs (#52752) 2026-10-02 15:09:38 +02:00
Filip f3a23e52b3 feat(core): support disable-model-invocation in skill frontmatter (#52747) 2026-10-02 15:07:30 +02:00
opencode-agent[bot] dbe08d2ac3 chore(core): refresh bundled models.dev snapshot 2026-10-02 12:22:07 +00:00
Shoubhit Dash 77eceb8c85 fix(core): merge formatter config across files (#52739) 2026-10-02 17:49:12 +05:30
Shoubhit Dash e2a700dc90 fix(acp): let the model continue when a question can't be shown (#52740) 2026-10-02 17:39:54 +05:30
Shoubhit Dash ebad4bc84c feat(acp): report compaction with standard session updates (#52737) 2026-10-02 17:39:24 +05:30
opencode-agent[bot] d82b75f090 chore: update nix node_modules hashes 2026-10-02 11:32:57 +00:00
Shoubhit Dash 134d0ede4d fix(acp): align permission requests with the tool call spec (#52565) 2026-10-02 16:51:36 +05:30
Shoubhit Dash 4a2c024ea7 fix(acp): detach new and resumed sessions when setup fails (#52558) 2026-10-02 16:07:33 +05:30
Shoubhit Dash 4cb56a829e fix(acp): send remote image links as references (#52557) 2026-10-02 16:06:58 +05:30
opencode-agent[bot]andBrendonovich 41516c78c8 fix(app): restore Git initialization for non-Git projects (#51455)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-02 06:13:19 +00:00
152 changed files with 6930 additions and 5769 deletions

No files matched your search

+1
View File
@@ -133,6 +133,7 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
}
}
+1 -1
View File
@@ -1223,7 +1223,7 @@ const gateway = CloudflareAIGateway.configure({
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
```
Included LLM providers: OpenAI, Anthropic, Google (Gemini), Google Vertex, Amazon Bedrock, Azure OpenAI, Baseten, Cerebras, Cloudflare AI Gateway, Cloudflare Workers AI, DeepInfra, DeepSeek, Fireworks, Groq, Mistral, OpenRouter, TogetherAI, and xAI. Z.ai currently exposes image generation. Generic Chat Completions, Responses, and Anthropic Messages-compatible entrypoints support custom endpoints.
Included LLM providers: OpenAI, Anthropic, Google (Gemini), Google Vertex, Amazon Bedrock, Azure OpenAI, Baseten, Cerebras, Cohere, Cloudflare AI Gateway, Cloudflare Workers AI, DeepInfra, DeepSeek, Fireworks, Groq, Mistral, OpenRouter, TogetherAI, and xAI. Z.ai currently exposes image generation. Generic Chat Completions, Responses, and Anthropic Messages-compatible entrypoints support custom endpoints.
Each named provider owns its module, endpoint, authentication, and route setup. Providers with the same wire format compose the shared protocol directly:
+326
View File
@@ -0,0 +1,326 @@
import { Effect, Schema } from "effect"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { LLMEvent, Usage, type FinishReasonDetails, type LLMRequest } from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "cohere-chat"
export const DEFAULT_BASE_URL = "https://api.cohere.com/v2"
const Options = Schema.Struct({
thinking: Schema.optional(
Schema.Struct({
type: Schema.optional(Schema.Literals(["enabled", "disabled"])),
tokenBudget: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
),
})
export type ProviderOptionsInput = Schema.Schema.Type<typeof Options>
const Content = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("thinking"), thinking: Schema.String }),
Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.Struct({ url: Schema.String }) }),
])
const ToolCall = Schema.Struct({
id: Schema.String,
type: Schema.Literal("function"),
function: Schema.Struct({ name: Schema.String, arguments: Schema.String }),
})
const Message = Schema.Struct({
role: Schema.Literals(["system", "user", "assistant", "tool"]),
content: Schema.optional(Schema.Union([Schema.String, Schema.Array(Content)])),
tool_calls: Schema.optional(Schema.Array(ToolCall)),
tool_call_id: Schema.optional(Schema.String),
tool_plan: Schema.optional(Schema.String),
})
const Body = Schema.Struct({
model: Schema.String,
messages: Schema.Array(Message),
stream: Schema.Literal(true),
tools: Schema.optional(
Schema.Array(
Schema.Struct({
type: Schema.Literal("function"),
function: Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
parameters: Schema.Unknown,
}),
}),
),
),
tool_choice: Schema.optional(Schema.Literals(["NONE", "REQUIRED"])),
thinking: Schema.optional(Schema.Struct({ type: Schema.String, token_budget: Schema.optional(Schema.Number) })),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
p: Schema.optional(Schema.Number),
k: Schema.optional(Schema.Number),
seed: Schema.optional(Schema.Number),
stop_sequences: Schema.optional(Schema.Array(Schema.String)),
frequency_penalty: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
})
const TokenCounts = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
reasoning_tokens: Schema.optional(Schema.Number),
})
const NativeUsage = Schema.Struct({
tokens: Schema.optional(TokenCounts),
billed_units: Schema.optional(TokenCounts),
cached_tokens: Schema.optional(Schema.Number),
})
const Event = Schema.Union([
Schema.Struct({ type: Schema.Literal("message-start") }),
Schema.Struct({
type: Schema.Literals(["content-start", "content-delta"]),
index: Schema.Number,
delta: Schema.Struct({
message: Schema.Struct({
content: Schema.Struct({ text: Schema.optional(Schema.String), thinking: Schema.optional(Schema.String) }),
}),
}),
}),
Schema.Struct({ type: Schema.Literal("content-end"), index: Schema.Number }),
Schema.Struct({
type: Schema.Literal("tool-plan-delta"),
delta: Schema.Struct({ message: Schema.Struct({ tool_plan: Schema.String }) }),
}),
Schema.Struct({
type: Schema.Literals(["tool-call-start", "tool-call-delta"]),
index: Schema.Number,
delta: Schema.Struct({
message: Schema.Struct({
tool_calls: Schema.Struct({
id: Schema.optional(Schema.String),
function: Schema.Struct({ name: Schema.optional(Schema.String), arguments: Schema.optional(Schema.String) }),
}),
}),
}),
}),
Schema.Struct({ type: Schema.Literal("tool-call-end"), index: Schema.Number }),
Schema.Struct({
type: Schema.Literal("message-end"),
delta: Schema.Struct({ finish_reason: Schema.String, usage: Schema.optional(NativeUsage) }),
}),
// Citation output is outside this basic chat surface.
Schema.Struct({ type: Schema.Literals(["citation-start", "citation-end"]) }),
])
type Event = typeof Event.Type
type State = {
readonly lifecycle: Lifecycle.State
readonly tools: ToolStream.State<number>
readonly finished: boolean
}
const TOOL_CHOICE = { auto: undefined, none: "NONE", required: "REQUIRED", tool: "REQUIRED" } as const
const fromRequest = Effect.fn("CohereChat.fromRequest")(function* (request: LLMRequest) {
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) }]
: []
for (const message of flattened.request.messages) {
if (message.role === "system") {
messages.push({ role: "user", content: (yield* ProviderShared.wrappedSystemUpdate("Cohere Chat", message)).text })
continue
}
if (message.role === "tool") {
for (const part of message.content) {
if (part.type !== "tool-result")
return yield* ProviderShared.unsupportedContent("Cohere Chat", "tool", ["tool-result"])
if (part.result.type === "content" && part.result.value.some((item) => item.type === "file"))
return yield* ProviderShared.invalidRequest("Cohere Chat does not support file content in tool results")
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
}
continue
}
const content: (typeof Content.Type)[] = []
const calls: (typeof ToolCall.Type)[] = []
const plans: string[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text })
continue
}
if (message.role === "assistant" && part.type === "reasoning") {
if (part.providerMetadata?.cohere?.toolPlan === true) plans.push(part.text)
else content.push({ type: "thinking", thinking: part.text })
continue
}
if (message.role === "assistant" && part.type === "tool-call") {
const args = ProviderShared.encodeJson(part.input)
calls.push({ id: part.id, type: "function", function: { name: part.name, arguments: args } })
continue
}
if (message.role === "user" && part.type === "media" && part.media.mediaType.startsWith("image/")) {
const url =
ProviderShared.mediaUrl(part.media) ??
(yield* ProviderShared.requireInlineMedia("Cohere Chat", part.media)).dataUrl
content.push({ type: "image_url", image_url: { url } })
continue
}
return yield* ProviderShared.unsupportedContent(
"Cohere Chat",
message.role,
message.role === "user" ? ["text", "media"] : ["text", "reasoning", "tool-call"],
)
}
messages.push({
role: message.role,
content: content.length ? content : undefined,
tool_calls: calls.length ? calls : undefined,
tool_plan: plans.length ? plans.join("") : undefined,
})
}
const selected = request.toolChoice?.type === "tool" ? request.toolChoice.name : undefined
const tools = selected === undefined ? flattened.tools : flattened.tools.filter((tool) => tool.name === selected)
if (selected !== undefined && tools.length === 0)
return yield* ProviderShared.invalidRequest("Cohere Chat tool choice must name an available tool")
if (tools.some((tool) => tool.native !== undefined))
return yield* ProviderShared.invalidRequest("Cohere Chat does not support provider-defined tools")
return {
model: request.model.id,
messages,
stream: true as const,
tools: tools.length
? tools.map((tool) => ({
type: "function" as const,
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema },
}))
: undefined,
tool_choice: TOOL_CHOICE[request.toolChoice?.type ?? "auto"],
thinking: options.thinking && {
type: options.thinking.type ?? "enabled",
// Cohere rejects budgets above max_tokens; fitting also leaves room for the answer.
token_budget:
options.thinking.tokenBudget === undefined
? undefined
: ProviderShared.fitThinkingBudget(options.thinking.tokenBudget, request.generation?.maxTokens),
},
max_tokens: request.generation?.maxTokens,
temperature: request.generation?.temperature,
p: request.generation?.topP,
k: request.generation?.topK,
seed: request.generation?.seed,
stop_sequences: request.generation?.stop,
frequency_penalty: request.generation?.frequencyPenalty,
presence_penalty: request.generation?.presencePenalty,
}
})
const finishReason = (raw: string): FinishReasonDetails => {
switch (raw) {
case "COMPLETE":
case "STOP_SEQUENCE":
return { normalized: "stop", raw }
case "MAX_TOKENS":
return { normalized: "length", raw }
case "TOOL_CALL":
return { normalized: "tool-calls", raw }
case "ERROR":
case "TIMEOUT":
return { normalized: "error", raw }
default:
return { normalized: "unknown", raw }
}
}
const mapUsage = (usage: typeof NativeUsage.Type) =>
new Usage({
inputTokens: usage.tokens?.input_tokens,
outputTokens: usage.tokens?.output_tokens,
nonCachedInputTokens: ProviderShared.subtractTokens(usage.tokens?.input_tokens, usage.cached_tokens),
cacheReadInputTokens: usage.cached_tokens,
reasoningTokens: usage.tokens?.reasoning_tokens,
totalTokens: ProviderShared.totalTokens(usage.tokens?.input_tokens, usage.tokens?.output_tokens, undefined),
providerMetadata: { cohere: usage },
})
// Lifecycle deltas open blocks on demand and ends are no-ops for closed blocks, so content-start needs no handling.
const step = Effect.fn("CohereChat.step")(function* (state: State, event: Event) {
const events: LLMEvent[] = []
switch (event.type) {
case "message-start":
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, events] as const
case "content-delta": {
const id = String(event.index)
const content = event.delta.message.content
const lifecycle =
content.thinking !== undefined
? Lifecycle.reasoningDelta(state.lifecycle, events, id, content.thinking)
: Lifecycle.textDelta(state.lifecycle, events, id, content.text ?? "")
return [{ ...state, lifecycle }, events] as const
}
case "content-end": {
const id = String(event.index)
const lifecycle = Lifecycle.textEnd(Lifecycle.reasoningEnd(state.lifecycle, events, id), events, id)
return [{ ...state, lifecycle }, events] as const
}
case "tool-plan-delta": {
const plan = event.delta.message.tool_plan
const lifecycle = Lifecycle.reasoningDelta(state.lifecycle, events, "tool-plan", plan, {
cohere: { toolPlan: true },
})
return [{ ...state, lifecycle }, events] as const
}
case "tool-call-start":
case "tool-call-delta": {
const call = event.delta.message.tool_calls
const result = ToolStream.appendOrStart(
ADAPTER,
state.tools,
event.index,
{ id: call.id, name: call.function.name, text: call.function.arguments ?? "" },
"Cohere tool call is missing id or name",
)
if (ToolStream.isError(result)) return yield* result
return [{ ...state, tools: result.tools }, result.events] as const
}
case "tool-call-end": {
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
return [{ ...state, tools: result.tools }, result.events ?? []] as const
}
case "message-end": {
const pending = yield* ToolStream.finishAll(ADAPTER, state.tools)
events.push(...pending.events)
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: finishReason(event.delta.finish_reason),
usage: event.delta.usage && mapUsage(event.delta.usage),
})
return [{ tools: pending.tools, lifecycle, finished: true }, events] as const
}
default:
return [state, events] as const
}
})
export const protocol = Protocol.make({
id: ADAPTER,
body: { schema: Body, from: fromRequest },
stream: {
event: Protocol.jsonEvent(Event),
initial: (): State => ({ lifecycle: Lifecycle.initial(), tools: ToolStream.empty(), finished: false }),
step,
terminal: (event) => event.type === "message-end",
onHalt: (state) =>
state.finished
? Effect.succeed([])
: Effect.fail(ProviderShared.eventError(ADAPTER, "Cohere stream ended without message-end")),
},
})
export const route = Route.make({
id: ADAPTER,
provider: "cohere",
providerMetadataKey: "cohere",
protocol,
endpoint: Endpoint.path("/chat", { baseURL: DEFAULT_BASE_URL }),
framing: Framing.sse,
})
export * as CohereChat from "./cohere-chat.js"
+1
View File
@@ -1,5 +1,6 @@
export * as AnthropicMessages from "./anthropic-messages.js"
export * as BedrockConverse from "./bedrock-converse.js"
export * as CohereChat from "./cohere-chat.js"
export * as Gemini from "./gemini.js"
export * as MistralChat from "./mistral-chat.js"
export * as OpenAIChat from "./openai-chat.js"
-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({
+7 -1
View File
@@ -55,7 +55,13 @@ const patterns = [
const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
const exclusions = [
/^(throttling error|service unavailable):/i,
/rate limit/i,
/too many requests/i,
// Cohere reports an output limit above the model maximum as "too many tokens"; compaction cannot fix it.
/max[_ ]tokens must be less than/i,
]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
+66
View File
@@ -0,0 +1,66 @@
import { CohereChat } from "../protocols/cohere-chat.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type ModelID, type OpenString } from "../schema/index.js"
import type { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("cohere")
const COMPATIBILITY_BASE_URL = "https://api.cohere.ai/compatibility/v1"
export type ChatOptionsInput = { readonly reasoningEffort?: OpenString<"none" | "high"> }
export type ProviderOptions = CohereChat.ProviderOptionsInput & ChatOptionsInput
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export type Settings<Options = CohereChat.ProviderOptionsInput> = ProviderPackage.Settings &
Options & { readonly apiKey?: string; readonly baseURL?: string }
export const route = CohereChat.route
export const chatRoute = Route.make({
id: "cohere-chat-completions",
provider: id,
providerMetadataKey: "cohere",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: COMPATIBILITY_BASE_URL }),
framing: OpenAIChat.framing,
})
export const routes = [route, chatRoute]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const auth = AuthOptions.bearer(input, "COHERE_API_KEY")
const native = route.with({ ...defaults, auth, endpoint: { baseURL: baseURL ?? CohereChat.DEFAULT_BASE_URL } })
const chat = chatRoute.with({ ...defaults, auth, endpoint: { baseURL: baseURL ?? COMPATIBILITY_BASE_URL } })
return {
id,
model: (modelID: string | ModelID) => native.model<CohereChat.ProviderOptionsInput>({ id: modelID }),
chat: (modelID: string | ModelID) =>
chat.model<ChatOptionsInput>({
id: modelID,
compatibility: {
maxTokensField: "max_tokens",
supportsStore: false,
supportsUsageInStreaming: true,
reasoningField: "reasoning_content",
supportsStrictMode: false,
},
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, CohereChat.ProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Cohere from "./cohere.js"
+16
View File
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { Cohere } from "../cohere.js"
export type Settings = Cohere.Settings<Cohere.ChatOptionsInput>
export const model: ProviderPackage.Definition<Settings, Cohere.ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
Cohere.configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).chat(modelID)
+1
View File
@@ -9,6 +9,7 @@ export * as Baseten from "./baseten.js"
export * as BlackForestLabs from "./black-forest-labs.js"
export * as Cartesia from "./cartesia.js"
export * as Cerebras from "./cerebras.js"
export * as Cohere from "./cohere.js"
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
export * as DeepInfra from "./deepinfra.js"
@@ -0,0 +1,49 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:cohere",
"provider:cohere"
],
"name": "cohere/continues-a-native-tool-call",
"recordedAt": "2026-10-02T02:36:16.143Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cohere.com/v2/chat",
"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}"
},
"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
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cohere.com/v2/chat",
"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}"
},
"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
}
}
]
}
@@ -0,0 +1,31 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:cohere",
"provider:cohere"
],
"name": "cohere/streams-compatible-chat-reasoning",
"recordedAt": "2026-10-02T02:39:47.406Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cohere.ai/compatibility/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-reasoning-08-2025\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 17 times 23? Answer briefly.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"high\",\"max_tokens\":2048}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\"\",\"role\":\"assistant\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\"Okay\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\",\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" so\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" I\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" need\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" to\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" figure\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" out\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" what\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" 1\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\"7\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" multiplied\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" by\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" 2\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\"3\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" is\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"indLine truncated
}
}
]
}
@@ -0,0 +1,31 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:cohere",
"provider:cohere"
],
"name": "cohere/streams-native-text-and-usage",
"recordedAt": "2026-10-02T02:36:12.967Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cohere.com/v2/chat",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-03-2025\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly: OK\"}]}],\"stream\":true,\"max_tokens\":64}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message-start\ndata: {\"id\":\"a171b3a5-db11-4445-a645-5221fb466ab8\",\"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\":\"text\",\"text\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"text\":\"OK\"}}}}\n\nevent: content-end\ndata: {\"type\":\"content-end\",\"index\":0}\n\nevent: message-end\ndata: {\"type\":\"message-end\",\"delta\":{\"finish_reason\":\"COMPLETE\",\"usage\":{\"billed_units\":{\"input_tokens\":4,\"output_tokens\":1},\"tokens\":{\"input_tokens\":499,\"output_tokens\":4},\"cached_tokens\":464}}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -0,0 +1,31 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:cohere",
"provider:cohere"
],
"name": "cohere/streams-native-thinking-with-a-budget",
"recordedAt": "2026-10-02T02:36:14.669Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cohere.com/v2/chat",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-reasoning-08-2025\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 17 times 23? Answer briefly.\"}]}],\"stream\":true,\"thinking\":{\"type\":\"enabled\",\"token_budget\":128},\"max_tokens\":2048}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message-start\ndata: {\"id\":\"305edbbe-9f86-4bc0-9490-7766b168a453\",\"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\":\"Okay\"}}}}\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\":\" let\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"'s\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" see\"}}}}\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\":\" figure\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" out\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" what\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 1\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"7\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" multiplied\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" by\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 2\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"3\"}}}}\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\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" H\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"mm\"}}}}\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\":\" how\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" do\"}}}}\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\":\" do\"}}}}\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\":\"?\"}}}}\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\":\" remember\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" some\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" multiplication\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" methods\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",Line truncated
}
}
]
}
+21
View File
@@ -462,6 +462,27 @@ describe("provider error rawBody classification", () => {
expect(reason._tag === "InvalidRequest" ? reason.classification : reason._tag).toBe("context-overflow")
})
test("separates Cohere prompt overflow from output limit rejections", () => {
const classify = (message: string) => {
const reason = classifyProviderFailure({
message,
status: 400,
rawBody: JSON.stringify({ error_type: "TOO_MANY_TOKENS", message }),
})
return reason._tag === "InvalidRequest" ? reason.classification : reason._tag
}
expect(
classify(
"too many tokens: size limit exceeded by 168512 tokens. Try using shorter or fewer inputs. The limit for this model is 132000 tokens.",
),
).toBe("context-overflow")
expect(
classify(
"too many tokens: max tokens must be less than or equal to 4096, the maximum output length for this model - received 1000000.",
),
).toBeUndefined()
})
test("classifies invalid API keys reported as HTTP 400 as authentication failures", () => {
const rawBody = JSON.stringify({
error: {
+19
View File
@@ -121,6 +121,25 @@ describe("provider package entrypoints", () => {
})
})
test("maps Cohere entrypoints onto native and compatibility routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/cohere"),
import("@opencode/ai/providers/cohere/chat"),
])
const settings = { apiKey: "fixture", headers: { "x-test": "fixture" }, body: { future_option: true } }
const routes = [
["cohere-chat", "https://api.cohere.com/v2"],
["cohere-chat-completions", "https://api.cohere.ai/compatibility/v1"],
]
modules.forEach((module, index) => {
const selected = module.model("command-a-03-2025", settings)
expect(selected.provider).toBe("cohere")
expect([selected.route.id, selected.route.endpoint.baseURL]).toEqual(routes[index])
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
})
})
test("maps MiniMax API entrypoints onto provider-owned routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/minimax"),
@@ -0,0 +1,90 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } 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"
import { recordedTests } from "../recorded-test.js"
const recorded = recordedTests({ prefix: "cohere", provider: "cohere", requires: ["COHERE_API_KEY"] })
const cohere = Cohere.configure({ apiKey: process.env.COHERE_API_KEY ?? "fixture" })
recorded.effect(
"streams native text and usage",
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: cohere.model("command-a-03-2025"),
prompt: "Reply exactly: OK",
generation: { maxTokens: 64 },
}),
)
expect(response.text.trim()).toMatch(/^OK\.?$/)
expect(response.usage.inputTokens).toBeGreaterThan(0)
expect(response.usage.outputTokens).toBeGreaterThan(0)
expect(response.events.find(LLMEvent.is.finish)?.reason).toEqual({ normalized: "stop", raw: "COMPLETE" })
expect(response.usage.providerMetadata?.cohere?.billed_units).toBeDefined()
}),
60_000,
)
recorded.effect(
"streams native thinking with a budget",
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: cohere.model("command-a-reasoning-08-2025"),
prompt: "What is 17 times 23? Answer briefly.",
providerOptions: { thinking: { type: "enabled", tokenBudget: 128 } },
generation: { maxTokens: 2048 },
}),
)
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.text).toContain("391")
expect(response.usage.reasoningTokens).toBeGreaterThan(0)
expect(response.usage.reasoningTokens).toBeLessThanOrEqual(128)
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}),
60_000,
)
recorded.effect(
"continues a native tool call",
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
)
expectWeatherToolLoop(events)
expect(events.some(LLMEvent.is.toolInputDelta)).toBe(true)
}),
60_000,
)
recorded.effect(
"streams compatible chat reasoning",
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: cohere.chat("command-a-reasoning-08-2025"),
prompt: "What is 17 times 23? Answer briefly.",
providerOptions: { reasoningEffort: "high" },
generation: { maxTokens: 2048 },
}),
)
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.text).toContain("391")
expect(response.events.find(LLMEvent.is.finish)?.reason.normalized).toBe("stop")
expect(response.usage.inputTokens).toBeGreaterThan(0)
expect(response.usage.reasoningTokens).toBeGreaterThan(0)
}),
60_000,
)
+266
View File
@@ -0,0 +1,266 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMClient, LLMEvent, Media, Message, 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"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const cohere = Cohere.configure({ apiKey: "fixture" })
test("Cohere exposes native and compatible endpoints without Core remapping", () => {
expect(cohere.model("command-a-03-2025").route.endpoint.baseURL).toBe("https://api.cohere.com/v2")
expect(cohere.chat("command-a-03-2025").route.endpoint.baseURL).toBe("https://api.cohere.ai/compatibility/v1")
expect(
Cohere.model("command-a-03-2025", { apiKey: "fixture", headers: { "X-Test": "yes" }, body: { temperature: 0 } })
.route.defaults?.http,
).toMatchObject({ body: { temperature: 0 } })
})
it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: cohere.model("command-a-reasoning-08-2025"),
system: "Be concise.",
messages: [
Message.user("Lookup Paris"),
Message.assistant([{ type: "tool-call", id: "lookup-1", name: "lookup", input: { city: "Paris" } }]),
Message.tool({ id: "lookup-1", name: "lookup", result: { sunny: true } }),
],
tools: [{ name: "lookup", description: "Look up a city", inputSchema: { type: "object", properties: {} } }],
toolChoice: "required",
providerOptions: { thinking: { tokenBudget: 128 } },
generation: { maxTokens: 2048, topP: 0.9, topK: 10 },
}),
)
expect(prepared.body).toMatchObject({
model: "command-a-reasoning-08-2025",
stream: true,
p: 0.9,
k: 10,
max_tokens: 2048,
thinking: { type: "enabled", token_budget: 128 },
tool_choice: "REQUIRED",
messages: [
{ role: "system", content: "Be concise." },
{ role: "user", content: [{ type: "text", text: "Lookup Paris" }] },
{
role: "assistant",
tool_calls: [{ id: "lookup-1", function: { name: "lookup", arguments: '{"city":"Paris"}' } }],
},
{ role: "tool", tool_call_id: "lookup-1", content: '{"sunny":true}' },
],
})
}),
)
it.effect("Cohere compatibility omits unsupported OpenAI fields", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: cohere.chat("command-a-reasoning-08-2025"),
prompt: "Hello",
providerOptions: { reasoningEffort: "high" },
generation: { maxTokens: 64 },
}),
)
expect(prepared.body).toMatchObject({ reasoning_effort: "high", max_tokens: 64, stream: true })
expect(prepared.body.stream_options).toEqual({ include_usage: true })
for (const key of ["store", "max_completion_tokens", "parallel_tool_calls", "prompt_cache_key"])
expect(prepared.body[key]).toBeUndefined()
}),
)
it.effect("Cohere maps inclusive usage while retaining distinct billed units", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message-start" },
{ type: "content-start", index: 0, delta: { message: { content: { type: "thinking", thinking: "" } } } },
{ type: "content-delta", index: 0, delta: { message: { content: { thinking: "Think" } } } },
{ type: "content-end", index: 0 },
{ type: "content-start", index: 1, delta: { message: { content: { type: "text", text: "" } } } },
{ type: "content-delta", index: 1, delta: { message: { content: { text: "OK" } } } },
{ type: "content-end", index: 1 },
{
type: "message-end",
delta: {
finish_reason: "COMPLETE",
usage: {
tokens: { input_tokens: 100, output_tokens: 20, reasoning_tokens: 10 },
billed_units: { input_tokens: 30, output_tokens: 15 },
cached_tokens: 60,
},
},
},
),
),
),
)
expect(response.text).toBe("OK")
expect(response.reasoning).toBe("Think")
expect(response.usage).toMatchObject({
inputTokens: 100,
nonCachedInputTokens: 40,
cacheReadInputTokens: 60,
outputTokens: 20,
reasoningTokens: 10,
totalTokens: 120,
providerMetadata: { cohere: { billed_units: { input_tokens: 30, output_tokens: 15 } } },
})
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}),
)
it.effect("Cohere rejects incomplete streams", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }),
).pipe(Effect.provide(fixedResponse(sseEvents({ type: "message-start" }))), Effect.flip)
expect(error.message).toContain("without message-end")
}),
)
it.effect("Cohere preserves native tool plans in continued history", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message-start" },
{ type: "tool-plan-delta", delta: { message: { tool_plan: "Look up the weather." } } },
{
type: "tool-call-start",
index: 0,
delta: { message: { tool_calls: { id: "lookup-1", function: { name: "lookup", arguments: "" } } } },
},
{
type: "tool-call-delta",
index: 0,
delta: { message: { tool_calls: { function: { arguments: '{"city":"Paris"}' } } } },
},
{ type: "tool-call-end", index: 0 },
{ type: "message-end", delta: { finish_reason: "TOOL_CALL" } },
),
),
),
)
expect(response.toolCalls[0]?.input).toEqual({ city: "Paris" })
const prepared = yield* compileRequest(
LLM.request({
model: cohere.model("command-a-03-2025"),
messages: [response.message, Message.tool({ id: "lookup-1", name: "lookup", result: { sunny: true } })],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_plan: "Look up the weather.", tool_calls: [{ id: "lookup-1" }] },
{ role: "tool", tool_call_id: "lookup-1" },
])
}),
)
it.effect("Cohere rejects unsupported media instead of silently dropping it", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: cohere.model("command-a-03-2025"),
messages: [Message.user([{ type: "media", media: Media.base64("Zm9v", "audio/wav") }])],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
it.effect("Cohere thinking budgets must be positive integers", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: cohere.model("command-a-reasoning-08-2025"),
providerOptions: { thinking: { tokenBudget: 0 } },
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
it.effect("Cohere fits thinking budgets under the output limit", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: cohere.model("command-a-reasoning-08-2025"),
prompt: "Hi",
providerOptions: { thinking: { tokenBudget: 31_999 } },
generation: { maxTokens: 4096 },
}),
)
expect(prepared.body.thinking).toEqual({ type: "enabled", token_budget: 2048 })
}),
)
// Bodies captured live on 2026-10-02, except 402 and 429, which are Cohere's documented messages.
const errors = [
{ status: 401, message: "Incorrect API key provided: ***-123.", tag: "Authentication", retry: false },
{ status: 404, message: "model 'no-such-model-xyz' not found", tag: "InvalidRequest", retry: false },
{
status: 400,
message: "invalid request: temperature must be between 0 and 2.0 inclusive.",
tag: "InvalidRequest",
retry: false,
},
{
status: 400,
error_type: "TOO_MANY_TOKENS",
message: "too many tokens: size limit exceeded by 168512 tokens. The limit for this model is 132000 tokens.",
tag: "InvalidRequest",
classification: "context-overflow",
retry: false,
},
{
status: 400,
error_type: "TOO_MANY_TOKENS",
message:
"too many tokens: max tokens must be less than or equal to 4096, the maximum output length for this model - received 1000000.",
tag: "InvalidRequest",
retry: false,
},
{ status: 402, message: "Please add or update your payment method to continue", tag: "QuotaExceeded", retry: false },
{
status: 429,
message: "You are using a Trial key, which is limited to 40 API calls / minute.",
tag: "RateLimit",
retry: true,
},
{ status: 500, message: "internal server error", tag: "ProviderInternal", retry: true },
]
it.effect("Cohere HTTP errors map to AI error reasons", () =>
Effect.forEach(errors, (item) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }),
).pipe(
Effect.provide(
fixedResponse(JSON.stringify({ id: "fixture", error_type: item.error_type, message: item.message }), {
status: item.status,
headers: { "content-type": "application/json" },
}),
),
Effect.flip,
)
expect({
message: error.message,
tag: error.reason._tag,
classification: error.reason._tag === "InvalidRequest" ? error.reason.classification : undefined,
retry: isRetryable(error),
}).toEqual({ message: item.message, tag: item.tag, classification: item.classification, retry: item.retry })
}),
),
)
@@ -8,6 +8,29 @@ import { expectSessionTitle } from "../utils/waits"
test.use({ viewport: { width: 1440, height: 900 } })
for (const view of ["desktop", "mobile"] as const) {
test(`offers Git initialization for a project without VCS (${view})`, async ({ page }) => {
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
const requests: { directory: string; provider?: string }[] = []
const workspace = await openSession(page, {
name: "ReviewWithoutGit",
project: { vcs: undefined },
onVcsInit: (input) => requests.push(input),
})
if (view === "desktop") await page.getByRole("button", { name: "Toggle review" }).click()
else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
const panel = view === "desktop" ? page.locator("#review-panel") : page.locator('[data-component="session-review"]')
await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible()
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
const init = panel.getByRole("button", { name: "Create Git repository" })
await init.click()
await expect.poll(() => requests).toEqual([{ directory: workspace.directory, provider: "git" }])
await expect(panel.getByRole("button", { name: "Git changes" })).toBeVisible()
await expect(init).toHaveCount(0)
})
}
test("open file tab browses, searches, and tracks missing files", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
const directory = "C:/OpenCode/ReviewOpenFile"
@@ -200,9 +200,11 @@ test("terminal stacks under review by default and spans the bottom when configur
await expect
.poll(() =>
page.evaluate(() => {
const review = document.querySelector("#review-panel")!.getBoundingClientRect()
const terminal = document.querySelector("#terminal-panel")!.getBoundingClientRect()
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')!
const review = document.querySelector("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect()
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')
// The side region appears before its lazy review content after reload.
if (!review || !terminal || !sidebar) return false
const gap = terminal.top - review.bottom
return (
terminal.left <= 9 &&
+1
View File
@@ -169,6 +169,7 @@ const Group = HttpApiGroup.make("mock")
)
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.post("vcsInit", "/api/vcs/init", { success: NoContent, error: Unsupported }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { query: Query, success: Json }))
+20
View File
@@ -43,6 +43,8 @@ export interface MockServerConfig {
cursor?: string
}
vcs?: { current: string; default: string }
// Initializes the mock project's VCS; without a handler this mutation answers 501.
onVcsInit?: (input: { directory: string; provider?: string }) => void
vcsDiff?: unknown[] | ((input: { mode?: string }) => unknown[])
// Benchmark latency only. Tests hold message pages with `beforeMessagesResponse`.
messageDelay?: number
@@ -817,6 +819,24 @@ function mockHandlers(
location: location(config),
data: { branch: config.vcs ?? { current: "main", default: "main" } },
}),
vcsInit: (ctx) => {
if (!config.onVcsInit) return unsupported("initialize VCS", "onVcsInit")
return Effect.sync(() => {
const url = new URL(ctx.request.url, "http://localhost")
const provider = url.searchParams.get("provider") ?? undefined
config.onVcsInit?.({ directory: requestDirectory(config, ctx.request), provider })
const project = config.project as { id: string; vcs?: string }
project.vcs = provider ?? "git"
state.emit([
{
id: "evt_vcs_initialized",
type: "worktree.updated",
created: Date.now(),
data: { projectID: project.id },
},
])
}).pipe(Effect.andThen(noContent))
},
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsBranches: () => Effect.succeed({ location: location(config), data: ["main"] }),
vcsDiff: (ctx) =>
+1
View File
@@ -39,6 +39,7 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
+9 -9
View File
@@ -9,12 +9,13 @@ import {
type JsonRpcId,
type Stream,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/promise"
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"
import { ACPError } from "./error"
import { ACPPromise } from "./promise"
import { ACPService } from "./service"
import { ACPSessions } from "./sessions"
import { ACPTurn } from "./turn"
@@ -23,19 +24,20 @@ import { ACPTurn } from "./turn"
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
const run = Effect.runPromiseWith(yield* Effect.context<Scope.Scope>())
const catalog = yield* ACPCatalog.make(client)
// Requests can dispatch once the stream's read loop yields, which may be before the service below is built.
// Requests can dispatch before the service below is built.
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)(
(ctx: AgentHandlerContext<Params>) =>
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
Effect.catchTags({
ACPCatalogLoadError: (error) => ACPPromise.classify(error.cause),
ACPCatalogNotReadyError: (error) => Effect.die(error),
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
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)),
@@ -102,8 +104,6 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
"session/set_mode",
handle((service, ctx) => service.setSessionMode(ctx.params)),
)
// The SDK signal is passed through rather than interrupting the fiber: a cancelled turn still resolves with
// `stopReason: "cancelled"`.
request(
"session/prompt",
handle((service, ctx) => service.prompt(ctx.params, ctx.signal)),
@@ -115,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 })
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"
+40 -45
View File
@@ -1,24 +1,25 @@
import type { CommandInfo, ModelInfo, ModelRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client/promise"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
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"
// ACP runs these itself; they take precedence over server commands with the same name.
export const builtinCommands = new Map([
["compact", { description: "Compact the session", start: "compaction" as const }],
])
export type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
readonly defaultModel: ModelRef
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
/** Server commands, without those shadowed by a built-in. */
readonly commands: CommandInfo[]
readonly models: ReadonlyArray<Model.Info>
readonly defaultModel: Model.Ref
readonly modes: ReadonlyArray<{ id: Agent.ID; name: string; description?: string }>
readonly defaultModeID: Agent.ID
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() {
@@ -26,32 +27,26 @@ 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(),
}) {}
export type Error = NotReadyError | LoadError
export interface Interface {
/** Loads a directory's catalog once. Concurrent callers share the load, and a failed load is not cached. */
readonly get: (cwd: string) => Effect.Effect<Catalog, Error>
/** Resolves after a reload that started after the call. A failed reload keeps the previous catalog. */
readonly reload: (cwd: string) => Effect.Effect<void, Error>
/** Emits the current catalog, then each reloaded one. */
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
}
// Provider, integration, and credential changes reach the catalog through model.updated.
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
@@ -59,21 +54,20 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
const entries = new Map<string, Deferred.Deferred<Entry, Error>>()
const connected = yield* Deferred.make<void>()
// A reload covers every request made before it starts, so requests queued behind a running reload share
// one more load. Typed load failures keep the previous catalog and still settle the requests they covered.
// 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
}),
),
)
@@ -82,7 +76,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
})
// Subscribe before the first read so an update between the read and the subscription is not lost.
yield* Stream.fromAsyncIterable(client.event.subscribe(), (cause) => cause).pipe(
yield* client.event.subscribe().pipe(
Stream.runForEach((event) => {
if (event.type === "server.connected") return Deferred.succeed(connected, undefined)
if (!reloadOn.has(event.type)) return Effect.void
@@ -94,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,
)
@@ -105,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
})
@@ -127,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)
}),
@@ -136,12 +132,12 @@ 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) =>
read(client, cwd).pipe(
// Some providers discover models in the background after plugin startup begins.
// Providers may still be discovering models after startup.
Effect.retry({
while: (error) => error._tag === "ACPCatalogNotReadyError",
schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })),
@@ -151,19 +147,18 @@ const load = (client: OpenCodeClient, cwd: string) =>
const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
const location = { directory: cwd }
const [modelResult, defaultResult, agentResult, commandResult] = yield* Effect.tryPromise({
try: (signal) =>
Promise.all([
client.model.list({ location }, { signal }),
client.model.default({ location }, { signal }),
client.agent.list({ location }, { signal }),
client.command.list({ location }, { signal }),
]),
catch: (cause) => new LoadError({ cause }),
})
const [modelResult, defaultResult, agentResult, commandResult] = yield* Effect.all(
[
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
],
{ concurrency: "unbounded" },
).pipe(Effect.mapError((cause) => new LoadError({ cause })))
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// Parallel reads can straddle initialization; select only from this model list.
// The parallel default read can name a model missing from this list.
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
@@ -186,7 +181,7 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
} satisfies Catalog
})
function providers(models: readonly ModelInfo[]): 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"
+32
View File
@@ -0,0 +1,32 @@
import { ClientError } from "@opencode/client/effect"
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"
import { ACPError } from "./error"
export function classify(error: unknown): Effect.Effect<never, ACPError.Error> {
if (
error instanceof ClientError &&
HttpClientError.isHttpClientError(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)
}
export function decodeSessionID(value: string) {
return Schema.decodeUnknownEffect(Session.ID)(value).pipe(
Effect.mapError(() => new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
)
}
export * as ACPClient from "./client"
+144
View File
@@ -0,0 +1,144 @@
import type { SessionUpdate } from "@agentclientprotocol/sdk"
import type { OpenCodeEvent } from "@opencode/client/effect"
import type { SessionError } from "@opencode/schema/session-error"
import { SessionMessage } from "@opencode/schema/session-message"
const MarkerMeta = "opencode/compaction"
type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
type Compaction =
| Started
| {
readonly status: "completed"
readonly messageId: string
readonly reason: "auto" | "manual"
readonly summary: string
}
| {
readonly status: "failed"
readonly messageId: string
readonly reason: "auto" | "manual"
readonly error: SessionError.Error
}
export type Tracked = ReadonlyMap<string, Started>
type LifecycleEvent = Extract<
OpenCodeEvent,
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
>
type OpeningEvent = Extract<
LifecycleEvent,
{ readonly type: "session.compaction.started" | "session.compaction.failed" }
>
const Cancelled = new Set(["aborted", "compaction.interrupted"])
// Without child updates, a child's standard update would read as the parent's own.
export function usesStandardUpdates(
ctx: { readonly compaction: boolean; readonly childUpdates: boolean },
child: boolean,
) {
return ctx.compaction && (!child || ctx.childUpdates)
}
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, standardUpdates)] }
}
const current = tracked.get(sessionID)
const remaining = new Map(tracked)
remaining.delete(sessionID)
if (event.type === "session.compaction.ended") {
if (!current) return { tracked: remaining, updates: [] }
const completed: Compaction = {
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
}
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 || !standardUpdates ? [] : [update(started, standardUpdates)]),
update(failed, standardUpdates),
],
}
}
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, 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" }>, 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 },
standardUpdates,
)
}
// Matches core's compaction message ID, so live and replayed compactions line up.
function open(event: OpeningEvent): Started {
return {
status: "started",
messageId: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
reason: event.data.reason,
}
}
function update(compaction: Compaction, standardUpdates: boolean): SessionUpdate {
if (!standardUpdates) {
const marker = {
status: compaction.status,
messageId: compaction.messageId,
reason: compaction.reason,
...(compaction.status === "failed" ? { error: compaction.error } : {}),
}
return { sessionUpdate: "session_info_update", _meta: { [MarkerMeta]: marker } }
}
const compactionId = compaction.messageId
if (compaction.status === "started")
return { sessionUpdate: "compaction_update", compactionId, status: "in_progress" }
if (compaction.status === "completed") {
const summary = compaction.summary ? [{ type: "text" as const, text: compaction.summary }] : null
return { sessionUpdate: "compaction_update", compactionId, status: "completed", summary }
}
if (Cancelled.has(compaction.error.type))
return { sessionUpdate: "compaction_update", compactionId, status: "cancelled", summary: null }
return {
sessionUpdate: "compaction_update",
compactionId,
status: "failed",
summary: null,
error: displayError(compaction.error),
}
}
// Core reports a defect as `compaction.failed` with a multi-line pretty-printed cause.
function displayError(error: SessionError.Error) {
if (!error.message || (error.type === "compaction.failed" && error.message.includes("\n"))) return "Compaction failed"
return error.message
}
export * as ACPCompaction from "./compaction"
+6 -8
View File
@@ -1,10 +1,11 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import type { ModelRef } from "@opencode/client/promise"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import { builtinCommands, type Catalog } from "./catalog"
export const DEFAULT_VARIANT_VALUE = "default"
export type ConfigOptionModel = {
type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
@@ -16,7 +17,7 @@ export type ConfigOptionProvider = {
models: ReadonlyArray<ConfigOptionModel>
}
export type ConfigOptionMode = {
type ConfigOptionMode = {
id: string
name: string
description?: string
@@ -27,10 +28,9 @@ export type ModelSelection = {
variant?: string
}
/** A session's model and mode. Unset fields follow the catalog defaults. */
export type Selection = {
readonly model?: ModelRef
readonly modeID?: string
readonly model?: Model.Ref
readonly modeID?: Agent.ID
}
export function currentModel(catalog: Catalog, selection: Selection) {
@@ -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"
+17 -26
View File
@@ -5,46 +5,31 @@ import {
type CreateElicitationRequest,
type CreateElicitationResponse,
type JsonRpcId,
type RequestError,
RequestError,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type Stream,
} from "@agentclientprotocol/sdk"
import { Context, Deferred, Effect } from "effect"
import type { ACPError } from "./error"
import { ACPPromise } from "./promise"
/**
* Completes once the response to the request being handled is written, so messages sent afterwards follow it.
* Interrupts when the request fails. Outside a request it completes immediately.
*/
export const Responded = Context.Reference<Effect.Effect<void>>("@opencode/cli/acp/Connection/Responded", {
defaultValue: () => Effect.void,
})
export interface Interface {
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, ACPError.Error | RequestError>
/** Interruption cancels the client's request. */
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, RequestError>
readonly requestPermission: (
params: RequestPermissionRequest,
) => Effect.Effect<RequestPermissionResponse, ACPError.Error | RequestError>
readonly extNotification: (
method: string,
params: Record<string, unknown>,
) => Effect.Effect<void, ACPError.Error | RequestError>
/** Interruption cancels the client's request. */
) => Effect.Effect<RequestPermissionResponse, RequestError>
readonly extNotification: (method: string, params: Record<string, unknown>) => Effect.Effect<void, RequestError>
readonly createElicitation: (
params: CreateElicitationRequest,
) => Effect.Effect<CreateElicitationResponse, ACPError.Error | RequestError>
/** Tracks an incoming request from now on and returns its `Responded`. */
) => Effect.Effect<CreateElicitationResponse, RequestError>
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) {
// Settled as each response is written to the stream, which serializes every outgoing message.
const responses = new Map<JsonRpcId, Deferred.Deferred<void>>()
const writer = stream.writable.getWriter()
const agent = app.connect({
@@ -64,15 +49,15 @@ export function make(app: AgentApp, stream: Stream) {
})
return {
agent,
connection: Service.of({
sessionUpdate: (params) => ACPPromise.promise(() => agent.client.notify(methods.client.session.update, params)),
connection: {
sessionUpdate: (params) => promise(() => agent.client.notify(methods.client.session.update, params)),
requestPermission: (params) =>
ACPPromise.promise((signal) =>
promise((signal) =>
agent.client.request(methods.client.session.requestPermission, params, { cancellationSignal: signal }),
),
extNotification: (method, params) => ACPPromise.promise(() => agent.client.notify(method, params)),
extNotification: (method, params) => promise(() => agent.client.notify(method, params)),
createElicitation: (params) =>
ACPPromise.promise((signal) =>
promise((signal) =>
agent.client.request(methods.client.elicitation.create, params, { cancellationSignal: signal }),
),
responded: (requestId) => {
@@ -80,8 +65,14 @@ export function make(app: AgentApp, stream: Stream) {
responses.set(requestId, responded)
return Deferred.await(responded)
},
}),
} satisfies Interface,
}
}
function promise<A>(evaluate: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
Effect.catch((cause) => (cause instanceof RequestError ? Effect.fail(cause) : Effect.die(cause))),
)
}
export * as ACPConnection from "./connection"
+27 -43
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
@@ -24,48 +25,30 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] {
}
return [{ type: "text", text: block.text }]
}
case "image":
if (block.data) {
return [
{
type: "file",
url: `data:${block.mimeType};base64,${block.data}`,
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
return []
case "image": {
const url = block.data ? `data:${block.mimeType};base64,${block.data}` : block.uri
if (!url) return []
const filename = filenameFromUri(block.uri ?? undefined) ?? "image"
if (url.startsWith("data:")) return [{ type: "file", url, filename, mime: block.mimeType }]
return [resourceLinkToPart({ uri: url, name: filename, mimeType: block.mimeType })]
}
case "resource_link":
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 [
@@ -180,8 +163,9 @@ export function linkReference(name: string | undefined, uri: string): PromptPart
function filenameFromUri(uri: string | undefined): string | undefined {
if (!uri || uri.startsWith("data:")) return undefined
if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
if (URL.canParse(uri)) {
const url = new URL(uri)
return path.basename((url.protocol === "zed:" && url.searchParams.get("path")) || url.pathname) || undefined
}
return path.basename(uri) || undefined
}
export * as ACPContent from "./content"
+13 -25
View File
@@ -1,20 +1,17 @@
import { isAbsolute, join, resolve } from "node:path"
import { isDeepStrictEqual } from "node:util"
import type { OpenCodeClient, PermissionRule, SessionInfo, SessionMetadata } from "@opencode/client/promise"
import type { OpenCodeClient } from "@opencode/client/effect"
import type { Permission } from "@opencode/schema/permission"
import type { Session } from "@opencode/schema/session"
import { FSUtil } from "@opencode/util/fs-util"
import { Effect, Option, Schema } from "effect"
import { ACPClient } from "./client"
import { ACPError } from "./error"
import { ACPPromise } from "./promise"
const key = "opencode.acp.additionalDirectories"
const decodeStored = Schema.decodeUnknownOption(Schema.Array(Schema.String))
/**
* Normalizes additional workspace roots without following symlinks, keeping the spelling tools will see, and
* drops duplicates and roots that resolve to cwd. Glob characters are rejected because permission resources
* would treat them as wildcards. The ACP SDK has already dropped entries that are not strings and replaced a
* value that is not an array with `[]`.
*/
// Permission resources treat `*` and `?` as wildcards.
export const parse = Effect.fnUntraced(function* (cwd: string, directories: readonly string[] = []) {
const invalid = directories.find((directory) => !isAbsolute(directory) || /[*?]/.test(directory))
if (invalid !== undefined) return yield* new ACPError.InvalidAdditionalDirectoryError({ directory: invalid })
@@ -24,27 +21,18 @@ export const parse = Effect.fnUntraced(function* (cwd: string, directories: read
)
})
/** Session create fields that grant these directories. */
export function grant(directories: readonly string[]) {
if (directories.length === 0) return {}
return { permissions: rules(directories), metadata: { [key]: [...directories] } }
}
/** The additional directories ACP last activated for the session, in request order. */
export function list(session: Pick<SessionInfo, "metadata">) {
export function list(session: Pick<Session.Info, "metadata">) {
return [...Option.getOrElse(decodeStored(session.metadata?.[key]), () => [])]
}
/**
* Replaces the rules granted for the previously activated directories with grants for these directories.
* Grants persist on the server session, so they also apply when it is used from other clients, and child
* sessions copy them when they are created; a child created earlier keeps roots its parent later dropped.
* Session rules are evaluated after agent and config rules, so a grant overrides config `external_directory`
* rules inside the root, while read, edit, and shell rules still apply.
*/
export const activate = Effect.fnUntraced(function* (
client: OpenCodeClient,
session: SessionInfo,
session: Session.Info,
directories: readonly string[],
) {
const previous = list(session)
@@ -55,24 +43,24 @@ export const activate = Effect.fnUntraced(function* (
...rules(directories),
...current.filter((rule) => !owned.some((item) => isDeepStrictEqual(item, rule))),
]
const metadata: SessionMetadata = {
const metadata: Session.Metadata = {
...Object.fromEntries(Object.entries(session.metadata ?? {}).filter(([name]) => name !== key)),
...(directories.length > 0 ? { [key]: [...directories] } : {}),
}
const permissionsChanged = !isDeepStrictEqual(permissions, current)
const metadataChanged = !isDeepStrictEqual(previous, directories)
if (!permissionsChanged && !metadataChanged) return
yield* ACPPromise.promise(() =>
client.session.update({
yield* client.session
.update({
sessionID: session.id,
...(permissionsChanged ? { permissions } : {}),
...(metadataChanged ? { metadata } : {}),
}),
)
})
.pipe(Effect.catch(ACPClient.classify))
})
// Tools compare the written path without following symlinks, so both spellings of a root are granted.
function rules(directories: readonly string[]): PermissionRule[] {
function rules(directories: readonly string[]): Permission.Rule[] {
return [...new Set(directories.flatMap((directory) => [directory, FSUtil.resolve(directory)]))].map((directory) => ({
action: "external_directory",
resource: join(directory, "*"),
+50 -50
View File
@@ -4,42 +4,37 @@ import type {
ElicitationSchema,
EnumOption,
} from "@agentclientprotocol/sdk"
import { isFormAlreadySettledError, isFormNotFoundError, type OpenCodeClient } from "@opencode/client/promise"
import type { OpenCodeClient } from "@opencode/client/effect"
import { Form } from "@opencode/schema/form"
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"
/** A form as the event stream carries it, with an unbranded ID. */
export type AskedForm = Omit<Form.Info, "id"> & { readonly id: string }
type InputField = Exclude<Form.Field, Form.ExternalField>
type SelectField = Form.StringField | Form.MultiselectField
// Form mode must not collect secrets, so only forms from flows known not to ask for credentials are elicited.
const ElicitedKind = Schema.Struct({ kind: Schema.Literals(["question", "websearch.provider"]) })
const Credential = /password|passphrase|secret|token|api[_-]?key|credential|private[_-]?key/i
const QuestionKind = "question"
// Form mode must not collect secrets; elicit only flows known to be credential-free.
const ElicitedKind = Schema.Struct({ kind: Schema.Literals([QuestionKind, "websearch.provider"]) })
const Credential = /password|passphrase|secret|token|api[_ -]?key|credential|private[_ -]?key/i
const ToolSource = Schema.Struct({ tool: Schema.Struct({ id: Schema.String }) })
type Input = {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Interface
readonly form: AskedForm
readonly form: Form.Info
readonly requestedSchema: ElicitationSchema
readonly clientSessionID: string
readonly child?: { readonly id: string; readonly title?: string }
/** Whether the asking tool call reached the client as a `session/update` tool call. */
readonly child?: ACPChild.Session
readonly toolCallSent: boolean
/** Completes once the form is answered or cancelled elsewhere. */
readonly settled: Effect.Effect<void>
}
type Outcome = Form.Answer | "cancel" | "settled"
/**
* Asks the client, then resolves the form on the server. Once `cancelled` completes, the client's request is
* cancelled or never sent, and the form is cancelled. Once `settled` completes, the client's request is cancelled and
* the server is left alone. Resolving on the server is uninterruptible.
*/
export const reply = Effect.fn("cli.acp.elicitation.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.
@@ -57,26 +52,25 @@ export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: In
)
})
/** Cancels a form, interrupting its session when the server can't cancel it. */
export function cancel(client: OpenCodeClient, form: AskedForm) {
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id })).pipe(
export const UnshownQuestionMessage =
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
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(() =>
Effect.tryPromise(() => client.session.interrupt({ sessionID: form.sessionID })).pipe(Effect.ignore),
ACPClient.decodeSessionID(form.sessionID).pipe(
Effect.flatMap((sessionID) => client.session.interrupt({ sessionID })),
Effect.ignore,
),
),
)
}
/**
* The form-mode schema for a form, or undefined when the form is cancelled instead: the client lacks form
* elicitation, the form is not from an allowed flow or has a field that looks like a credential, or ACP can't
* represent it faithfully. Unrepresentable forms have `external` fields, `when` conditions, a hidden required field
* without a default, a default outside a field's options, or a free-text answer alongside options that must also
* satisfy `required` or item bounds across both inputs. Hidden fields are not asked and answer with their default.
*/
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((field) => Credential.test(field.key) || Credential.test(field.title ?? ""))) return undefined
if (form.fields.some(credentialLike)) return undefined
const fields = form.fields.filter((field): field is InputField => field.type !== "external")
if (fields.length !== form.fields.length || fields.some((field) => field.when?.length)) return undefined
if (fields.some((field) => field.hidden && field.required && field.default === undefined)) return undefined
@@ -90,8 +84,14 @@ export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabi
}
}
/** The answer for an accepted response, or undefined when the user declined, cancelled, or sent invalid content. */
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
// The question tool returns the message to the model, so the turn continues instead of ending as interrupted.
export function cancelUnshown(client: OpenCodeClient, form: Form.Info) {
return cancel(client, form, form.metadata?.kind === QuestionKind ? UnshownQuestionMessage : undefined).pipe(
Effect.uninterruptible,
)
}
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
@@ -109,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"
@@ -119,21 +119,21 @@ const ask = Effect.fnUntraced(function* (input: Input) {
function respond(input: Input, outcome: Outcome) {
if (outcome === "settled") return Effect.void
if (outcome === "cancel") return cancel(input.client, input.form)
return settle(() =>
input.client.session.form.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome }),
).pipe(
Effect.catch((cause) =>
Effect.logWarning("ACP form reply failed", cause).pipe(Effect.andThen(cancel(input.client, input.form))),
),
)
return input.client.session.form
.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome })
.pipe(
Effect.catchTag(["FormAlreadySettledError", "FormNotFoundError"], () => Effect.void),
Effect.catch((cause) =>
Effect.logWarning("ACP form reply failed", cause).pipe(Effect.andThen(cancel(input.client, input.form))),
),
)
}
// A form already answered or cancelled elsewhere needs nothing more.
function settle(evaluate: () => Promise<void>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
Effect.catch((cause) =>
isFormAlreadySettledError(cause) || isFormNotFoundError(cause) ? Effect.void : Effect.fail(cause),
),
function credentialLike(field: Form.Field) {
const labels =
field.type === "string" || field.type === "multiselect" ? (field.options ?? []).map((option) => option.label) : []
return [field.key, field.title, field.description, ...labels].some(
(text) => text !== undefined && Credential.test(text),
)
}
@@ -153,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,
@@ -186,7 +187,6 @@ function properties(field: InputField): Array<[string, ElicitationPropertySchema
}
}
// A free-text answer next to a field's options is a separate optional property that wins over the selection.
function other(field: SelectField, description: string): [string, ElicitationPropertySchema] {
return [
customKey(field),
@@ -194,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, so the client is told it needs at least one character.
function text(field: Form.StringField) {
// Core rejects an empty string for a required field.
function stringConstraints(field: Form.StringField) {
return {
format: field.format,
minLength: field.required ? Math.max(field.minLength ?? 0, 1) : field.minLength,
+17 -19
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,24 +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 const is = Schema.is(Errors)
export type Failure = Error | RequestError | ACPCatalog.Error
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
@@ -120,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"
+79 -75
View File
@@ -1,24 +1,30 @@
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
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"
import type { Session } from "@opencode/schema/session"
import { Patch } from "@opencode/util/patch"
import { Cause, Effect } from "effect"
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 { ACPPromise } from "./promise"
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type Tool = { readonly name: string; readonly input: ToolInput }
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
type Preview = ReturnType<typeof diff>
type Input = {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Interface
readonly event: PermissionEvent
readonly sessionID: string
readonly sessionID: Session.ID
readonly clientSessionID: string
readonly cwd: string
readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
readonly child?: ACPChild.Session
readonly settled: Effect.Effect<void>
}
const options: PermissionOption[] = [
@@ -27,15 +33,18 @@ const options: PermissionOption[] = [
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
/**
* Asks the client, then replies to the server. Once `cancelled` completes, the client's request is cancelled or never
* sent, and the server gets `reject`. The server reply is uninterruptible, so a server that is alive but stuck can
* hold a cancel past `CancelDrainTimeout`; a dead server fails fast.
*/
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)),
@@ -45,23 +54,25 @@ export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Inp
const ask = Effect.fnUntraced(function* (input: Input) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
const toolCallID = input.event.data.source?.id ?? input.event.data.id
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
Effect.orElseSucceed((): Preview[] => []),
)
const title = permissionTitle(toolName, toolInput, previews)
const toolCall = pendingToolCall({
toolCallId: ACPChild.toolCallID(input.child, input.tool?.id ?? input.event.data.id),
toolName,
state: { input: toolInput, title: title ? ACPChild.prefixTitle(input.child, title) : input.child?.title },
cwd: input.cwd,
})
const result = yield* input.connection.requestPermission({
sessionId: input.clientSessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
...toolCall,
rawInput: input.tool ? toolCall.rawInput : undefined,
locations: permissionLocations(toolName, toolInput, input.event.data, input.cwd),
...(previews.length > 0 ? { content: previews } : {}),
...(input.child ? { _meta: ACPChild.meta(input.child) } : {}),
},
options,
})
@@ -69,41 +80,38 @@ const ask = Effect.fnUntraced(function* (input: Input) {
return selected === "once" || selected === "always" ? selected : "reject"
})
function respond(input: Input, decision: PermissionReplyInput["decision"]) {
return ACPPromise.promise(() =>
input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }),
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),
)
}
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
if (!prefix) return title
if (!title) return prefix
return `${prefix}: ${title}`
}
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
// Core trims the patch tool's diffs for display, which breaks `applyPatch`, so its previews come from its own hunks.
const permissionPreviews = Effect.fnUntraced(function* (
toolName: string,
input: ToolInput,
metadata: ToolInput | undefined,
cwd: string,
) {
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
const file = filePath(input)
if (!file) return []
const path = absolutePath(file, cwd)
if (tool === "write") {
const content = stringValue(input.content)
if (content === undefined) return []
const oldText = yield* readText(path)
return [diff(path, oldText, content)]
}
if (tool !== "edit") return []
const oldString = stringValue(input.oldString)
const newString = stringValue(input.newString)
if (oldString === undefined || newString === undefined) return []
const oldText = yield* readText(path)
const newText =
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
return [diff(path, oldText, newText)]
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
const previews = yield* Effect.forEach(
files,
(file) =>
Effect.gen(function* () {
const path = absolutePath(file.file, cwd)
const oldText = file.status === "added" ? null : yield* Effect.tryPromise(() => Bun.file(path).text())
const newText = yield* Effect.try(() => applyPatch(oldText ?? "", file.patch))
return newText === false ? [] : [diff(path, oldText, newText)]
}),
{ concurrency: "unbounded" },
)
return previews.flat()
})
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
function patchPreviews(input: ToolInput, cwd: string) {
return Effect.forEach(
patchHunks(input),
@@ -112,22 +120,22 @@ function patchPreviews(input: ToolInput, cwd: string) {
const path = absolutePath(hunk.path, cwd)
if (hunk.type === "add") {
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
return diff(path, "", newText)
return diff(path, null, newText)
}
const oldText = yield* readText(path)
const oldText = yield* Effect.tryPromise(() => Bun.file(path).text())
if (hunk.type === "delete") return diff(path, oldText, "")
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
}),
{ concurrency: "unbounded" },
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
)
}
function diff(path: string, oldText: string, newText: string): ToolCallContent {
return { type: "diff", path, oldText, newText }
function diff(path: string, oldText: string | null, newText: string) {
return { type: "diff" as const, path, oldText, newText }
}
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
if (previews.length > 1) return `${previews.length} files`
switch (toolName.toLocaleLowerCase()) {
case "external_directory":
@@ -144,26 +152,22 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
case "write":
case "patch":
case "apply_patch":
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
return filePath(input) ?? previews[0]?.path
default:
return undefined
}
}
function permissionLocations(
toolName: string,
input: ToolInput,
resources: ReadonlyArray<string>,
cwd: string,
): ToolCallLocation[] {
function permissionLocations(toolName: string, input: ToolInput, ask: PermissionEvent["data"], cwd: string) {
const locations = toLocations(toolName, input, cwd)
if (locations.length > 0) return locations
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
if (locations.length > 0 || !PathActions.has(ask.action)) return locations
const paths = ask.resources.flatMap((resource) => {
const path = resource.endsWith("/*") ? resource.slice(0, -2) : resource
return path && !/[*?]/.test(path) ? [absolutePath(path, cwd)] : []
})
return Array.from(new Set(paths), (path) => ({ path }))
}
// A missing file previews as empty.
function readText(path: string) {
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
}
const PathActions = new Set(["read", "edit", "external_directory"])
export * as ACPPermission from "./permission"
-21
View File
@@ -1,21 +0,0 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { ClientError, isInvalidRequestError, isSessionNotFoundError } from "@opencode/client/promise"
import { Effect } from "effect"
import { ACPError } from "./error"
/** Runs a promise, keeping ACP failures typed. Any other rejection is a defect. */
export const promise = <A>(evaluate: (signal: AbortSignal) => Promise<A>) =>
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(Effect.catch(classify))
export function classify(cause: unknown): Effect.Effect<never, ACPError.Error | RequestError> {
if (cause instanceof RequestError || ACPError.is(cause)) return Effect.fail(cause)
if (cause instanceof ClientError && cause.reason === "Transport")
return Effect.fail(new ACPError.ServerUnavailableError())
if (isSessionNotFoundError(cause))
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: cause.sessionID }))
if (isInvalidRequestError(cause))
return Effect.fail(new ACPError.InvalidRequestError({ message: cause.message, field: cause.field }))
return Effect.die(cause)
}
export * as ACPPromise from "./promise"
+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"
+98 -111
View File
@@ -1,12 +1,9 @@
import {
isInvalidRequestError,
isSessionNotFoundError,
type ModelRef,
type OpenCodeClient,
type SessionMessageInfo,
} from "@opencode/client/promise"
import type { OpenCodeClient } from "@opencode/client/effect"
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 { Effect, Option, Ref, Result, Stream } from "effect"
import { DateTime, Effect, Ref, Schema } from "effect"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import type {
AuthenticateRequest,
@@ -25,11 +22,11 @@ import type {
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
McpServer,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
RequestError,
ResumeSessionRequest,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
@@ -38,38 +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 { ACPPromise } from "./promise"
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
/** What the client advertised in `initialize`. */
export type Capabilities = { readonly childSessionUpdates: boolean; readonly formElicitation: 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,8 +78,7 @@ export function make(input: {
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
})
// A selection the catalog has not seen may be new on the server, so reload once before rejecting it.
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 }),
@@ -108,54 +100,33 @@ export function make(input: {
}
})
// Both selectors update the selection before switching on the server, so the echoed event diffs to no change.
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: ModelRef) {
// Update selection before switching so the echoed event is a no-op.
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: Model.Ref) {
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
yield* ACPPromise.promise(() => input.client.session.switchModel({ sessionID: attached.id, model }))
yield* input.client.session.switchModel({ sessionID: attached.id, model }).pipe(Effect.catch(ACPClient.classify))
})
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
const catalog = yield* input.catalog.get(attached.cwd)
if (!catalog.modes.some((mode) => mode.id === modeID)) return yield* new ACPError.InvalidModeError({ mode: modeID })
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID }))
yield* ACPPromise.promise(() => input.client.session.switchAgent({ sessionID: attached.id, agent: modeID }))
const mode = catalog.modes.find((item) => item.id === modeID)
if (!mode) return yield* new ACPError.InvalidModeError({ mode: modeID })
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID: mode.id }))
yield* input.client.session
.switchAgent({ sessionID: attached.id, agent: mode.id })
.pipe(Effect.catch(ACPClient.classify))
})
const getSession = Effect.fnUntraced(function* (sessionID: string, cwd: string) {
const session = yield* ACPPromise.promise(() => input.client.session.get({ sessionID }))
const getSession = Effect.fnUntraced(function* (sessionId: string, cwd: string) {
const sessionID = yield* ACPClient.decodeSessionID(sessionId)
const session = yield* input.client.session.get({ sessionID }).pipe(Effect.catch(ACPClient.classify))
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory))
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId, cwd })
return session
})
const replay = (attached: Attached) =>
Stream.paginate(undefined, (cursor: string | undefined) =>
ACPPromise.promise(() =>
cursor
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" }),
).pipe(Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const)),
).pipe(Stream.runForEach((message) => replayMessage(attached, message)))
// A message that fails to translate keeps the updates before the failure and does not stop the replay.
const replayMessage = Effect.fnUntraced(function* (attached: Attached, message: SessionMessageInfo) {
const updates = ACPTranslate.replayMessage(message, attached.cwd)
while (true) {
const next = yield* Effect.result(Effect.try(() => updates.next()))
if (Result.isFailure(next))
return yield* Effect.logWarning("ACP replay skipped the rest of a message", message.id, next.failure.cause)
if (next.success.done) return
yield* input.connection.sessionUpdate({ sessionId: attached.id, update: next.success.value })
}
})
return {
initialize: Effect.fnUntraced(function* (params) {
const elicitation = params.clientCapabilities?.elicitation
yield* Ref.set(input.capabilities, {
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
formElicitation: elicitation?.form !== undefined && elicitation.form !== 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",
@@ -173,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 },
@@ -186,38 +157,41 @@ export function make(input: {
}),
newSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
// Load before creating so a catalog failure leaves no session behind. Agent and model stay unset
// so the server resolves its defaults after plugins activate.
const mcpServers = yield* supportedMcpServers(params.mcpServers)
// Load first so a catalog failure leaves no session.
yield* input.catalog.get(params.cwd)
const created = yield* ACPPromise.promise(() =>
input.client.session.create({
location: { directory: params.cwd },
...ACPDirectories.grant(directories),
}),
)
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
return { sessionId: attached.id, configOptions: yield* currentOptions(attached) }
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, 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)
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) {
const page = yield* ACPPromise.promise(() =>
input.client.session.list({
...(params.cwd ? { directory: params.cwd } : {}),
const page = yield* input.client.session
.list({
...(params.cwd ? { directory: AbsolutePath.make(params.cwd) } : {}),
order: "desc",
limit: 100,
...(params.cursor ? { cursor: params.cursor } : {}),
}),
)
...(params.cursor ? { cursor: Schema.decodeSync(SessionsCursor)(params.cursor) } : {}),
})
.pipe(Effect.catch(ACPClient.classify))
return {
sessions: page.data.map((session) => {
const additionalDirectories = ACPDirectories.list(session)
@@ -225,30 +199,32 @@ export function make(input: {
sessionId: session.id,
cwd: session.location.directory,
...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
title: withTimestampedFallback(session),
updatedAt: new Date(session.time.updated).toISOString(),
title: withTimestampedFallback({
...session,
time: { created: DateTime.toEpochMillis(session.time.created) },
}),
updatedAt: DateTime.formatIso(session.time.updated),
}
}),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
}),
deleteSession: Effect.fnUntraced(function* (params) {
// A malformed ID fails the server's path decode, and the session ID is the only path param.
yield* ACPPromise.promise(() =>
input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (isSessionNotFoundError(error) || (isInvalidRequestError(error) && error.kind === "Params")) return
throw error
}),
yield* ACPClient.decodeSessionID(params.sessionId).pipe(
Effect.flatMap((sessionID) => input.client.session.remove({ sessionID })),
Effect.catchTag(["ACPInvalidRequestError", "SessionNotFoundError"], () => Effect.void),
Effect.catch(ACPClient.classify),
)
yield* input.sessions.detach(params.sessionId)
return {}
}),
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 attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: yield* currentOptions(attached) }
const attachment = yield* input.sessions.attach(session, session.location.directory, mcpServers)
return { configOptions: attachment.configOptions }
}),
closeSession: Effect.fnUntraced(function* (params) {
yield* input.turn.close(params.sessionId)
@@ -257,14 +233,13 @@ export function make(input: {
}),
forkSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
const forked = yield* ACPPromise.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
// Forks copy the source session's rules, so the request list replaces any inherited grants.
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 attached = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
return yield* currentOptions(attached).pipe(
Effect.map((configOptions) => ({ sessionId: attached.id, configOptions })),
Effect.onError(() => input.sessions.detach(attached.id)),
)
const attachment = yield* input.sessions.attach(forked, forked.location.directory, mcpServers)
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
}),
setSessionConfigOption: Effect.fnUntraced(function* (params) {
const attached = yield* input.sessions.require(params.sessionId)
@@ -283,29 +258,41 @@ export function make(input: {
}
}
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: ModelRef) {
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(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
)
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selected.variant ??
selectedVariant?.id ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant } satisfies ModelRef
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
})
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: ModelRef) {
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: Model.Ref) {
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
if (!model || (effort !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === effort)))
return yield* new ACPError.InvalidEffortError({ effort })
return { ...current, variant: effort } satisfies ModelRef
return { ...current, variant: Model.VariantID.make(effort) } satisfies Model.Ref
})
export * as ACPService from "./service"
+49 -58
View File
@@ -1,43 +1,38 @@
import { isDeepStrictEqual } from "node:util"
import type { McpServer, RequestError } from "@agentclientprotocol/sdk"
import type { OpenCodeClient, OpenCodeEvent, SessionInfo } from "@opencode/client/promise"
import { Context, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
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 { 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"
import { ACPPromise } from "./promise"
export type SupportedMcpServer = Exclude<McpServer, { readonly type: "acp" | "sse" }>
export type Attached = {
readonly id: string
readonly id: Session.ID
readonly cwd: string
readonly selection: Ref.Ref<Selection>
}
export interface Interface {
/**
* Attaches a session in its own scope, closing any previous attachment of the same ID. Once the attaching request
* has responded, the scope follows the cwd's catalog and pushes config option and command updates while it is
* open. A failed attach leaves the session detached.
*/
readonly attach: (
session: SessionInfo,
session: Session.Info,
cwd: string,
mcpServers: readonly McpServer[],
) => Effect.Effect<Attached, ACPError.Error | RequestError | ACPCatalog.Error>
/** Closes the session scope. No-op when the session is not attached. */
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>
/** Forks work into this attachment's scope, so it ends on detach or re-attach. Fails once the attachment is gone. */
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
/** Selection changes from other clients, applied by the session's fold. */
readonly selected: Queue.Queue<Selection>
}
@@ -50,12 +45,12 @@ export const make = Effect.fnUntraced(function* (input: {
}) {
const scope = yield* Effect.scope
const sessions = new Map<string, Entry>()
// Kept across re-attachment so resuming with the same servers does not add them again.
const registeredMcp = new Map<string, Set<string>>()
// Outlives entries so re-attaching does not re-add servers.
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.
yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
yield* input.client.event.subscribe().pipe(
Stream.tap((event) => (event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void)),
Stream.filter(
(event): event is SelectedEvent =>
@@ -69,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,
)
@@ -92,22 +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)
return ACPPromise.promise(() =>
input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }),
).pipe(
Effect.onError(() => Effect.sync(() => registered.delete(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(server.name))),
Effect.uninterruptible,
)
}),
@@ -119,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)
@@ -143,9 +138,8 @@ export const make = Effect.fnUntraced(function* (input: {
if (replaced) yield* Scope.close(replaced.scope, Exit.void)
yield* registerMcp(entry.attached, mcpServers).pipe(Effect.onError(() => remove(session.id, entry)))
const responded = yield* ACPConnection.Responded
// Updates wait for the response that hands the client this session. `changes` emits the latest catalog
// first, so a reload since `current` is still pushed. One fold applies catalog and selection changes so
// pushes leave the client on the latest pair.
// `changes` replays the latest catalog, so a reload since `current` still pushes.
// One fold keeps catalog and selection consistent.
yield* Effect.gen(function* () {
yield* responded
yield* sendCommands(session.id, current)
@@ -162,13 +156,20 @@ export const make = Effect.fnUntraced(function* (input: {
),
)
}).pipe(Effect.ignore, Effect.forkIn(entry.scope))
return entry.attached
return {
attached: entry.attached,
configOptions: configOptions(current, yield* Ref.get(entry.attached.selection)),
}
}),
detach: Effect.fn("cli.acp.sessions.detach")(function* (sessionID) {
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
@@ -178,33 +179,23 @@ 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 {
type: "remote" as const,
return new Mcp.RemoteConfig({
type: "remote",
url: server.url,
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
oauth: false as const,
}
oauth: false,
})
}
return {
type: "local" as const,
return new Mcp.LocalConfig({
type: "local",
command: [server.command, ...server.args],
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
}
}
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 -17
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,12 +99,11 @@ 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 {
const normalized = toolContent(input.content)
// Read's model content is a JSON page envelope; show the clean text instead.
const firstText = input.content.find((part) => part.type === "text")
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
@@ -139,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
@@ -148,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 } }],
@@ -159,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)
@@ -168,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
}
@@ -209,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"
+92 -261
View File
@@ -1,33 +1,27 @@
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
SessionMessageAssistant,
SessionMessageInfo,
SessionStructuredError,
TokenUsageInfo,
} from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import { SessionMessage } from "@opencode/schema/session-message"
import type { OpenCodeEvent } from "@opencode/client/effect"
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 { partsToContentChunks, type ReplayPart } from "./content"
import { ACPChild } from "./child"
import { ACPCompaction } from "./compaction"
import { ACPError } from "./error"
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"
const CompactionMeta = "opencode/compaction"
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: string }
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: SessionMessage.ID }
export type Terminal = "succeeded" | "failed" | "interrupted"
export type Context = {
readonly sessionID: string
readonly sessionID: Session.ID
readonly cwd: string
readonly start: TurnStart
readonly childUpdates: boolean
/** A background consumer follows open children after the parent turn ends; it never writes `session/update`. */
readonly compaction: boolean
/** Background mode follows open children after the turn ends and never writes `session/update`. */
readonly mode: "turn" | "background"
}
@@ -42,86 +36,49 @@ type Tool = {
type RetryStatus = {
readonly attempt: number
readonly nextRetryAt: string
readonly error: SessionStructuredError
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: ReadonlyMap<string, string>
readonly children: ReadonlyMap<string, ChildSession>
readonly compactions: ACPCompaction.Tracked
readonly children: ReadonlyMap<string, ACPChild.Session>
readonly openChildren: ReadonlySet<string>
/** Forms asked of the client that the server has not yet answered or cancelled. */
readonly forms: ReadonlySet<string>
readonly finish?: SessionMessageAssistant["finish"]
readonly usage?: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo }
readonly stepError?: SessionStructuredError
readonly asks: ReadonlySet<string>
readonly finish?: SessionMessage.Assistant["finish"]
readonly usage?: { readonly turn: TokenUsage.Info; readonly last: TokenUsage.Info }
readonly stepError?: SessionError.Error
readonly executionError?: { readonly type: string; readonly message: string }
}
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type FormEvent = Extract<EventSubscribeOutput, { type: "form.created" }>
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
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
/** Whether the form's session sends its tool calls to the client as `session/update` tool calls. */
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
}
type CompactionEvent = Extract<
EventSubscribeOutput,
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
>
type CompactionMarker = {
readonly status: "started" | "completed" | "failed"
readonly messageId: string
readonly reason: "auto" | "manual"
readonly error?: SessionStructuredError
}
export const initial: TurnState = {
started: false,
tools: new Map(),
@@ -129,10 +86,10 @@ 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: EventSubscribeOutput, ctx: Context): Step {
export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step {
if (event.type === "session.created") {
const parentID = event.data.parentID
if (!parentID) return { state, outputs: [] }
@@ -158,11 +115,14 @@ export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context
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",
@@ -173,13 +133,20 @@ export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context
],
}
}
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) {
@@ -207,11 +174,20 @@ export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context
case "session.compaction.started":
case "session.compaction.ended":
case "session.compaction.failed": {
const compacted = compactionMarker(event, state.compactions)
return {
state: { ...state, compactions: compacted.compactions },
outputs: compacted.marker ? send(compactionUpdate(compacted.marker)) : [],
}
const applied = ACPCompaction.apply(
event,
state.compactions,
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
)
return { state: { ...state, compactions: applied.tracked }, outputs: applied.updates.flatMap(send) }
}
case "session.compaction.delta": {
const update = ACPCompaction.chunk(
state.compactions.get(eventSessionID),
event.data.text,
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
)
return { state, outputs: update ? send(update) : [] }
}
case "session.text.delta":
return {
@@ -227,7 +203,7 @@ export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context
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 },
}),
}
@@ -345,7 +321,6 @@ export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context
}
}
/** The ACP failure a settled turn reports instead of a response, if any. */
export function failure(state: TurnState) {
const error = state.stepError ?? state.executionError
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
@@ -373,155 +348,63 @@ export function response(state: TurnState, sessionID: string, terminal: Terminal
: undefined
const error = (state.stepError ?? state.executionError)?.type
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
// Interruption clears the projected retry, so a retry pending at interrupt is reported here.
const retry = state.retries.get(sessionID)
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
}
/** Fails the tools a cancelled turn left open, for when the server's wind-down never reports them. */
export function abandonTools(state: TurnState, ctx: Context): Step {
// Child compactions are left to the background consumer.
export function abandon(state: TurnState, ctx: Context): Step {
const compaction = state.compactions.get(ctx.sessionID)
return {
state: { ...state, tools: new Map() },
outputs: [...state.tools.values()].flatMap((tool) =>
route(ctx, state.children.get(tool.sessionID), {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: tool.id,
toolName: tool.name,
input: tool.input,
metadata: tool.metadata,
content: [],
error: "Cancelled",
cwd: ctx.cwd,
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
outputs: [
...[...state.tools.values()].flatMap((tool) =>
route(ctx, state.children.get(tool.sessionID), {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: tool.id,
toolName: tool.name,
input: tool.input,
metadata: tool.metadata,
content: [],
error: "Cancelled",
cwd: ctx.cwd,
}),
}),
}),
),
),
...(compaction
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
: []),
],
}
}
/** Lazy, so a message that fails to translate part way still replays the updates before the failure. */
export function* replayMessage(message: SessionMessageInfo, cwd: string): 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
}
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
if (message.type === "compaction" && message.status !== "running") {
yield compactionUpdate({
status: message.status,
messageId: message.id,
reason: message.reason,
...(message.status === "failed" ? { error: message.error } : {}),
})
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) }]
}
// A background consumer ends once its last open child settles.
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)
@@ -533,7 +416,7 @@ function childEnded(
}
}
function recordStep(state: TurnState, tokens: TokenUsageInfo): TurnState {
function recordStep(state: TurnState, tokens: TokenUsage.Info): TurnState {
const turn = state.usage?.turn
return {
...state,
@@ -557,7 +440,7 @@ function without<K, V>(map: ReadonlyMap<K, V>, key: K) {
return next
}
function sessionIDFromEvent(event: EventSubscribeOutput) {
function sessionIDFromEvent(event: OpenCodeEvent) {
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
if (event.type === "form.created") return event.data.form.sessionID
return undefined
@@ -567,61 +450,9 @@ function toolKey(sessionID: string, id: string) {
return `${sessionID}:${id}`
}
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
function compactionMarker(event: CompactionEvent, compactions: ReadonlyMap<string, string>) {
const sessionID = event.data.sessionID
if (event.type === "session.compaction.started") {
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
const marker: CompactionMarker = { status: "started", messageId, reason: event.data.reason }
return { marker, compactions: new Map(compactions).set(sessionID, messageId) }
}
const tracked = compactions.get(sessionID)
const remaining = without(compactions, sessionID)
if (event.type === "session.compaction.ended") {
const marker: CompactionMarker | undefined = tracked
? { status: "completed", messageId: tracked, reason: event.data.reason }
: undefined
return { marker, compactions: remaining }
}
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
const marker: CompactionMarker = {
status: "failed",
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
reason: event.data.reason,
error: event.data.error,
}
return { marker, compactions: remaining }
}
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = {
...projected._meta,
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
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
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
}
function resolveStopReason(input: {
readonly terminal: Terminal
readonly finish: SessionMessageAssistant["finish"]
readonly finish: SessionMessage.Assistant["finish"]
readonly error?: string
}): PromptResponse["stopReason"] {
if (input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
+118 -121
View File
@@ -1,10 +1,6 @@
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
import {
isSessionNotFoundError,
type CommandInfo,
type OpenCodeClient,
type OpenCodeEvent,
} from "@opencode/client/promise"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
import type { Command } from "@opencode/schema/command"
import { SessionMessage } from "@opencode/schema/session-message"
import { TokenUsage } from "@opencode/schema/token-usage"
import {
@@ -24,38 +20,27 @@ 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"
import { linkReference, promptContentToParts, type PromptPart } from "./content"
import { ACPElicitation } from "./elicitation"
import { ACPError } from "./error"
import { ACPPermission } from "./permission"
import { ACPPromise } from "./promise"
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 {
/**
* Runs the session's only turn. Cancelling it, including through the request's `$/cancel_request` signal,
* interrupts the turn and still resolves with `stopReason: "cancelled"`.
*/
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
/** Interrupts the session's active turn and waits for it to settle. No-op when the session is idle. */
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, ACPError.Failure>
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
/** Like `cancel`, but an idle session is still interrupted, since server work can outlive its turn. */
/** 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") {}
/**
* How long a cancelled turn keeps forwarding the server's wind-down. Core acknowledges an interrupt before its
* cleanup settles, and its shell tool waits 3 seconds before escalating to SIGKILL.
*/
/** 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,21 +51,18 @@ type PreparedPrompt = {
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: CommandInfo
readonly command?: Command.Info
}
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
/** A turn's event feed. It moves to the session scope when the turn ends with children still running. */
type Subscription = {
readonly scope: Scope.Closeable
readonly events: Queue.Dequeue<OpenCodeEvent, unknown>
/** Runs permission and form asks one at a time in ask order, without holding back the rest of the stream. */
/** Asks run serially off the event stream. */
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
/** Completed when the turn is cancelled; pending and later asks then resolve without the client. */
readonly cancelled: Deferred.Deferred<void>
/** Completed per asked form once the server reports it answered or cancelled. */
readonly forms: Map<string, Deferred.Deferred<void>>
readonly settled: Map<string, Deferred.Deferred<void>>
}
export const make = Effect.fnUntraced(function* (input: {
@@ -88,24 +70,23 @@ 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, so it still closes when the session scope it is handed to is already gone.
// Parented to the service scope; the session scope may already be closed.
const subscriptionScope = yield* Scope.fork(scope)
const subscription: Subscription = {
scope: subscriptionScope,
events: yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
Stream.toQueue({ capacity: "unbounded" }),
Scope.provide(subscriptionScope),
),
events: yield* input.client.event
.subscribe()
.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,
@@ -121,18 +102,22 @@ export const make = Effect.fnUntraced(function* (input: {
const take = (subscription: Subscription) =>
Queue.take(subscription.events).pipe(
Effect.catch((error) =>
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPPromise.classify(error),
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPClient.classify(error),
),
)
// A turn settles only after the asks it saw have been resolved.
const asksSettled = Effect.fnUntraced(function* (subscription: Subscription) {
const settled = yield* Deferred.make<void>()
yield* Queue.offer(subscription.asks, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
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,
@@ -142,7 +127,8 @@ export const make = Effect.fnUntraced(function* (input: {
clientSessionID: ctx.sessionID,
cwd: ctx.cwd,
tool: ask.tool,
...(ask.child ? { toolCallPrefix: ask.child.id, titlePrefix: ask.child.title } : {}),
child: ask.child,
settled: Deferred.await(settled),
},
Deferred.await(subscription.cancelled),
)
@@ -153,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.cancel(input.client, output.form)
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(
@@ -181,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
})
}
@@ -197,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)
@@ -210,60 +208,56 @@ export const make = Effect.fnUntraced(function* (input: {
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
const sessionID = attached.id
if (prompt.synthetic.length > 0) {
yield* ACPPromise.promise((signal) =>
input.client.session.synthetic(
{
sessionID,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
},
{ signal },
),
)
yield* input.client.session
.synthetic({
sessionID,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
})
.pipe(Effect.catch(ACPClient.classify))
}
if (prompt.start.type === "compaction") {
yield* ACPPromise.promise((signal) =>
input.client.session.compact({ sessionID, id: prompt.start.id }, { signal }),
)
yield* input.client.session.compact({ sessionID, id: prompt.start.id }).pipe(Effect.catch(ACPClient.classify))
return
}
const command = prompt.command
if (command) {
yield* ACPPromise.promise((signal) =>
input.client.session.command(
{ sessionID, name: command.name, text: prompt.slash?.args ?? "", files: prompt.files, delivery: "steer" },
{ signal },
),
)
yield* input.client.session
.command({
sessionID,
name: command.name,
text: prompt.slash?.args ?? "",
files: prompt.files,
delivery: "steer",
})
.pipe(Effect.catch(ACPClient.classify))
return
}
yield* ACPPromise.promise((signal) =>
input.client.session.prompt(
{ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
{ signal },
),
)
yield* input.client.session
.prompt({ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" })
.pipe(Effect.catch(ACPClient.classify))
})
const interruptServer = (sessionID: string) =>
ACPPromise.promise(() => input.client.session.interrupt({ sessionID })).pipe(Effect.ignoreCause)
// Rejects pending asks, interrupts the server once, then forwards its wind-down until the terminal event or the
// timeout. Tools still open at the timeout are reported failed so the client never shows them running.
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.abandonTools(yield* Ref.get(state), ctx)
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
yield* Ref.set(state, abandoned.state)
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
Effect.ignore,
@@ -299,12 +293,12 @@ 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
if ((yield* Ref.get(state)).openChildren.size === 0) return yield* close
// Children that outlive a cancelled turn were not cancelled, so their asks still go to the client.
// Children outlive a cancelled turn, so their asks still reach the client.
const cancelled = yield* Deferred.make<void>()
const background = consume({ ...subscription, cancelled }, { ...ctx, mode: "background" }, state).pipe(
Effect.ignore,
@@ -317,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)
@@ -335,9 +329,7 @@ export const make = Effect.fnUntraced(function* (input: {
const current = currentModel(catalog, yield* Ref.get(attached.selection))
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
if (!model?.limit.context) return
const info = yield* ACPPromise.promise((signal) =>
input.client.session.get({ sessionID: attached.id }, { signal }),
)
const info = yield* input.client.session.get({ sessionID: attached.id }).pipe(Effect.catch(ACPClient.classify))
yield* input.connection.sessionUpdate({
sessionId: attached.id,
update: {
@@ -348,37 +340,39 @@ 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.
const run = Effect.fn("cli.acp.turn.run")(function* (
attached: Attached,
prompt: PreparedPrompt,
childUpdates: boolean,
) {
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: PreparedPrompt) {
const capabilities = yield* Ref.get(input.capabilities)
const state = yield* Ref.make(ACPTranslate.initial)
const ctx: ACPTranslate.Context = {
sessionID: attached.id,
cwd: attached.cwd,
start: prompt.start,
childUpdates,
childUpdates: capabilities.childSessionUpdates,
compaction: capabilities.compaction,
mode: "turn",
}
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
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)
const childUpdates = (yield* Ref.get(input.capabilities)).childSessionUpdates
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
}
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
concurrency: "unbounded",
})
const prompt = preparePrompt(catalog, parts, SessionMessage.ID.create())
// Check and register in one synchronous step.
// Synchronous, so concurrent prompts for one session cannot both register.
const turn = yield* Effect.withFiber((fiber) => {
if (FiberMap.hasUnsafe(turns, attached.id)) {
return Effect.fail(
@@ -388,7 +382,7 @@ export const make = Effect.fnUntraced(function* (input: {
}),
)
}
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt, childUpdates), { uninterruptible: true })
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt), { uninterruptible: true })
FiberMap.setUnsafe(turns, attached.id, forked)
return Effect.succeed(forked)
})
@@ -396,18 +390,18 @@ 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) {
if (FiberMap.hasUnsafe(turns, sessionID)) return yield* FiberMap.remove(turns, sessionID)
yield* ACPPromise.promise(() =>
input.client.session.interrupt({ sessionID }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
}),
yield* ACPClient.decodeSessionID(sessionID).pipe(
Effect.flatMap((id) => input.client.session.interrupt({ sessionID: id })),
Effect.catchTag(["ACPInvalidRequestError", "SessionNotFoundError"], () => Effect.void),
Effect.catch(ACPClient.classify),
)
}),
})
} satisfies Interface
})
function aborted(signal: AbortSignal) {
@@ -419,18 +413,26 @@ function aborted(signal: AbortSignal) {
})
}
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: string): PreparedPrompt {
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: SessionMessage.ID): PreparedPrompt {
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
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,
}
}
// Covers only missing or permission-denied targets; the server still rejects oversized, non-regular, or unlistable ones.
function referenceUnreadableFile(part: PromptPart) {
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
@@ -439,12 +441,7 @@ function referenceUnreadableFile(part: PromptPart) {
)
}
function turnStart(messageID: string, 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+/)
+12 -5
View File
@@ -1,8 +1,9 @@
import { MessageTooLargeError, ndJsonStream } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode/client/promise"
import { OpenCode } from "@opencode/client/effect"
import { Service } from "@opencode/client/effect/service"
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
import { Effect } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Writable } from "node:stream"
import { ACP } from "../../acp/agent"
import { Commands } from "../commands"
@@ -14,7 +15,15 @@ export default Runtime.handler(
Effect.fn("cli.acp")(function* () {
process.env.OPENCODE_CLIENT = "acp"
const endpoint = yield* Standalone.start()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const client = yield* OpenCode.make({ baseUrl: endpoint.url }).pipe(
Effect.provideServiceEffect(
HttpClient.HttpClient,
HttpClient.HttpClient.pipe(
Effect.map(HttpClient.mapRequest(HttpClientRequest.setHeaders(Service.headers(endpoint) ?? {}))),
),
),
Effect.provide(FetchHttpClient.layer),
)
const connection = yield* ACP.connect(client, ndJsonStream(Writable.toWeb(process.stdout), Bun.stdin.stream()))
const failure = yield* Effect.raceFirst(
Effect.promise(() => connection.closed).pipe(
@@ -33,10 +42,8 @@ export default Runtime.handler(
Effect.map((reason) => `server exited unexpectedly (${reason})`),
),
)
// Closing the handler scope would wait for the private server's graceful shutdown; its lease pipe already
// ends the server once this process exits.
// Exit directly: closing the scope would wait on the server's graceful shutdown, and its lease pipe ends it anyway.
yield* Effect.sync(() => {
// stdout carries ACP, so the diagnostic goes to stderr.
if (failure) process.stderr.write(`opencode acp: ${failure}\n`)
process.exit(failure ? 1 : 0)
})
@@ -20,7 +20,7 @@ export default Runtime.handler(
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest(method))
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
+33 -27
View File
@@ -98,7 +98,7 @@ export interface Interface {
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly latest: (method?: Method) => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
readonly removal: (
method: Method,
@@ -131,8 +131,8 @@ const make = Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
.readFileString(path.join(directory, "package.json"))
@@ -142,6 +142,10 @@ const make = Effect.gen(function* () {
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
return manifest.name
}).pipe(Effect.orElseSucceed(() => undefined))
// "opencode" is Homebrew Core's formula; the others are published to anomalyco/tap.
const installedFormula = ["opencode", "opencode-beta", "opencode-v2"].find((name) =>
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
)
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -178,13 +182,7 @@ const make = Effect.gen(function* () {
const method = Effect.fnUntraced(function* () {
if (path.resolve(process.execPath) === curlBinary) return "curl"
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
if (
["opencode-beta", "opencode-v2"].some((name) =>
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
)
)
return "brew"
if (installedFormula) return "brew"
if (!installedPackage) return
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
@@ -236,10 +234,14 @@ const make = Effect.gen(function* () {
const release = Effect.fnUntraced(function* (method?: Method) {
const distribution = method === "brew" ? "homebrew" : "npm"
// Homebrew Core builds its formula on its own schedule, so the tap release does not describe it.
const core = method === "brew" && installedFormula === "opencode"
const response = yield* Effect.tryPromise({
try: (signal) =>
fetch(
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
core
? "https://formulae.brew.sh/api/formula/opencode.json"
: `https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
{
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
@@ -262,19 +264,22 @@ const make = Effect.gen(function* () {
retry: "Try again in a few minutes.",
}),
)
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) =>
new UpgradeError(
{
title: "Could not read the OpenCode update information",
detail: errorDetail(cause),
retry: "Try again in a few minutes.",
},
{ cause },
),
})
if (!data.metadata?.package)
const data: { version?: string; metadata?: { package?: string }; versions?: { stable?: string } } =
yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) =>
new UpgradeError(
{
title: "Could not read the OpenCode update information",
detail: errorDetail(cause),
retry: "Try again in a few minutes.",
},
{ cause },
),
})
const version = core ? data.versions?.stable : data.version
const packageName = core ? "opencode" : data.metadata?.package
if (!version || !packageName)
return yield* Effect.fail(
new UpgradeError({
title: "Could not read the OpenCode update information",
@@ -282,11 +287,11 @@ const make = Effect.gen(function* () {
retry: "Try again in a few minutes.",
}),
)
return { package: data.metadata.package, version: data.version }
return { package: packageName, version }
})
const latest = () =>
method().pipe(
const latest = (selected?: Method) =>
(selected ? Effect.succeed(selected) : method()).pipe(
Effect.flatMap(release),
Effect.map((data) => data.version),
)
@@ -346,7 +351,8 @@ const make = Effect.gen(function* () {
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const packageName = (yield* release(method)).package
const packageName =
method === "brew" && installedFormula === "opencode" ? "opencode" : (yield* release(method)).package
const target = `${packageName}@${version}`
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
@@ -2,30 +2,16 @@ import type { NewSessionResponse, PromptResponse } from "@agentclientprotocol/sd
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { createAcpFixture, expectOk, initialize } from "./subprocess"
import { createAcpFixture, expectOk, initialize, toolCallStream } from "./subprocess"
// The first completion reads the file outside cwd; the follow-up completion ends the turn.
function readingModel(file: () => string) {
return (request: unknown) => {
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
return new Response(toolCall(file()), { headers: { "content-type": "text/event-stream" } })
return toolCallStream("call_read", "read", { path: file() })
}
}
function toolCall(file: string) {
const call = { index: 0, id: "call_read", type: "function", function: { name: "read", arguments: "" } }
const chunks = [
{ choices: [{ delta: { role: "assistant", tool_calls: [call] }, finish_reason: null }], usage: null },
{
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: file }) } }] } }],
usage: null,
},
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
]
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
}
describe("acp additional directories subprocess", () => {
test("tools read files in an additional directory without an external directory ask", async () => {
const target = { file: "" }
@@ -1,13 +1,11 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import type { PermissionRule } from "@opencode/client/promise"
import { tmpdir } from "../fixture/tmpdir"
import type { Permission } from "@opencode/schema/permission"
import { makeSession, rpcError, startWire, type Wire } from "./wire-fixture"
const key = "opencode.acp.additionalDirectories"
const grant = (directory: string): PermissionRule => ({
const grant = (directory: string): Permission.Rule => ({
action: "external_directory",
resource: path.join(directory, "*"),
effect: "allow",
@@ -17,9 +15,9 @@ const sharedLib = path.resolve("/shared/lib")
const productDocs = path.resolve("/product-docs")
const old = path.resolve("/old")
const userGrant: PermissionRule = { action: "external_directory", resource: "/x/**", effect: "allow" }
const userGrant: Permission.Rule = { action: "external_directory", resource: "/x/**", effect: "allow" }
const other: PermissionRule[] = [
const other: Permission.Rule[] = [
{ action: "read", resource: "*.secret", effect: "deny" },
{ action: "external_directory", resource: "/shared/lib/private/*", effect: "deny" },
]
@@ -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).toEqual({ location: { directory: "/workspace" } })
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,128 +0,0 @@
import { describe, expect } from "bun:test"
import { OpenCode } from "@opencode/client/promise"
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, 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("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).toEqual({ 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, ACPCatalog.make(OpenCode.make({ baseUrl: acp.server.url }))),
),
),
)
}
// 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 }
}
+55 -232
View File
@@ -1,200 +1,66 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import { SessionMessage } from "@opencode/schema/session-message"
import type { CompactionUpdate, SessionNotification } from "@agentclientprotocol/sdk"
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
import { Schema } from "effect"
import {
assistantMessage,
childCreated,
durableEvent,
ephemeralEvent,
makeSession,
startSession,
startWire,
stepEnded,
succeeded,
textDelta,
turn,
} from "./wire-fixture"
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 }))
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: "" }),
describe("acp standard compaction updates over the wire", () => {
test("streams a /compact turn's summary and completes it with the full summary", async () => {
const compacted = await compactTurn(
(sessionID, id) => [
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "Summary of " }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "the earlier conversation" }),
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
],
{ compaction: {} },
)
await using acp = compacted.acp
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId: compacted.id, status: "in_progress" }),
summaryChunk(acp.sessionId, compacted.id, "Summary of "),
summaryChunk(acp.sessionId, compacted.id, "the earlier conversation"),
compaction(acp.sessionId, {
compactionId: compacted.id,
status: "completed",
summary: [{ type: "text", text: summary }],
}),
])
expect(compacted.response.stopReason).toBe("end_turn")
})
test("treats a null compaction capability as unsupported", 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: "" }),
],
{ compaction: null },
)
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"),
])
})
})
// 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) => OpenCodeEvent[]) {
async function compactTurn(
events: (sessionID: string, id: string) => OpenCodeEventEncoded[],
capabilities: InitializeOptions,
) {
const held = Promise.withResolvers<Response>()
const acp = await startSession({ fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined) })
const acp = await startSession({
capabilities,
fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined),
})
const response = acp.prompt(acp.sessionId, "/compact")
const request = await acp.until(
() => acp.server.requests.find((item) => item.path.endsWith("/compact")),
@@ -202,33 +68,23 @@ async function compactTurn(events: (sessionID: string, id: string) => OpenCodeEv
)
const id = decodeCompact(request.body).id
acp.server.send(...turn(acp.sessionId, id, ...events(acp.sessionId, id)))
held.resolve(Response.json({ data: {} }))
held.resolve(enqueued(acp.sessionId, id, "compaction", {}))
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 controller = new AbortController()
const stream = OpenCode.make({ baseUrl: url }).event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
await stream.next()
return {
async messageID(type: OpenCodeEvent["type"]) {
while (true) {
const next = await stream.next()
if (next.done) throw new Error(`event stream ended before ${type}`)
if (next.value.type === type) return SessionMessage.ID.fromEvent(Event.ID.make(next.value.id))
}
},
[Symbol.dispose]: () => controller.abort(),
}
}
function marker(sessionId: string, value: Record<string, unknown>): SessionNotification {
return { sessionId, update: { sessionUpdate: "session_info_update", _meta: { "opencode/compaction": value } } }
}
function chunk(sessionId: string, messageId: string, text: string): SessionNotification {
return { sessionId, update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text } } }
function compaction(sessionId: string, update: CompactionUpdate): SessionNotification {
return { sessionId, update: { sessionUpdate: "compaction_update", ...update } }
}
function summaryChunk(sessionId: string, compactionId: string, text: string): SessionNotification {
return {
sessionId,
update: { sessionUpdate: "compaction_summary_chunk", compactionId, content: { type: "text", text } },
}
}
function turnUpdates(updates: readonly SessionNotification[]) {
@@ -236,36 +92,3 @@ function turnUpdates(updates: readonly SessionNotification[]) {
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
)
}
function compactedHistory(): SessionMessageInfo[] {
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)
})
-245
View File
@@ -1,245 +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 http URI becomes a file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/jpeg",
uri: "http://example.com/assets/photo.jpg",
}),
).toEqual([
{
type: "file",
url: "http://example.com/assets/photo.jpg",
filename: "photo.jpg",
mime: "image/jpeg",
},
])
})
test("resource_link file URL becomes a file part with name and fallback mime", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "file:///tmp/notes.txt",
name: "client-notes.txt",
}),
).toEqual([
{
type: "file",
url: "file:///tmp/notes.txt",
filename: "client-notes.txt",
mime: "text/plain",
},
])
})
test("resource_link 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",
},
},
},
])
})
})
+7 -345
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",
@@ -45,7 +42,7 @@ const questions = (sessionID: string, id = "frm_question", tool = "call_question
},
})
const capable = { childSessionUpdates: false, formElicitation: true }
const capable = { childSessionUpdates: false, formElicitation: true, compaction: false }
const form = (
fields: ACPElicitation.AskedForm["fields"],
@@ -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()
@@ -191,96 +65,13 @@ describe("acp elicitation mapping", () => {
[{ key: "api_key", type: "string" }],
[{ key: "q0", title: "GitHub token", type: "string" }],
[{ key: "q0", title: "Password", type: "string", hidden: true, default: "" }],
[{ key: "q0", title: "Setup", description: "Paste your API key", type: "string" }],
[{ key: "q0", title: "Setup", type: "string", options: [{ value: "a", label: "Use my access token" }] }],
]
expect(credentials.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
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", () => {
@@ -332,137 +123,8 @@ describe("acp elicitation over the wire", () => {
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toHaveLength(ids.length)
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(ids)
expect(acp.server.cancelledForms.map((item) => item.message)).toEqual(ids.map(() => undefined))
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("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: { name: "ClientError", reason: "UnexpectedStatus" } },
])
})
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")
+199 -455
View File
@@ -1,23 +1,20 @@
import { describe, expect, test } from "bun:test"
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import { Cause } from "effect"
import type { AnyRequest, CreateElicitationResponse, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
import { createTwoFilesPatch } from "diff"
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"
@@ -26,11 +23,24 @@ 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(
sessionID,
id,
toolStarted(sessionID, "call_once", "shell"),
toolCalled(sessionID, "call_once", { command: "printf hello" }),
toolStarted(sessionID, "call_always", "read"),
toolCalled(sessionID, "call_always", { path: "/workspace/file.ts" }),
permissionAsked(sessionID, "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
@@ -41,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,
@@ -79,487 +90,220 @@ 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" }],
rawInput: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
})
})
test("routes foreground child permissions through the parent ACP session", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Review code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
permissionAsked("ses_child", "perm_child", {
action: "read",
metadata: { path: "/workspace/child.ts" },
source: { type: "tool", messageID: "msg_child", id: "call_child" },
}),
succeeded("ses_child"),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions).toHaveLength(1)
expect(acp.permissions[0]).toMatchObject({
sessionId: acp.sessionId,
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts" },
})
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
})
test("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")
expect(acp.permissions).toMatchObject([
{
sessionId: acp.sessionId,
toolCall: { toolCallId: "ses_background:perm_background", title: "Research: /workspace/notes.md" },
},
])
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: { name: "ClientError", reason: "UnexpectedStatus" } },
])
})
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 edits during approval", async () => {
test("previews added files as new and deleted files as empty", async () => {
await using dir = await tmpdir()
const file = path.join(dir.path, "file.ts")
await fs.writeFile(file, "before")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "before", newString: "after" }),
permissionAsked(sessionID, "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
],
onPermissionReply: async ({ sessionID }) => {
await fs.writeFile(file, "after")
return [
toolSucceeded(sessionID, "call_edit", { files: [{ file: "file.ts" }], replacements: 1 }, "edited"),
succeeded(sessionID),
]
},
permission: allowOnce,
})
await acp.initialize()
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: "before", newText: "after" }],
})
})
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("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" },
}),
],
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],
])
})
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 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_edit", "edit"),
toolCalled(sessionID, "call_edit", { filePath: file, oldString: "before", newString: "after" }),
toolSucceeded(sessionID, "call_edit", { files: [{ file }] }, "edited"),
...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,
})
await acp.initialize({ writeTextFile: true })
await acp.initialize()
const session = await acp.newSession(dir.path)
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(acp.writes).toEqual([])
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" },
],
},
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([
[{ 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" },
],
[{ 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,
])
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])
}
function fileDiff(file: string, before: string, after: string, status: "added" | "deleted" | "modified" = "modified") {
return { file, patch: createTwoFilesPatch(file, file, before, after), additions: 1, deletions: 1, status }
}
@@ -7,7 +7,7 @@ import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("acp prompt content subprocess", () => {
test("accepts embedded text resource image and file resource link prompt content", async () => {
test("accepts embedded text resource, inline and remote image, and file resource link prompt content", async () => {
await using fixture = await createAcpFixture()
await Bun.write(path.join(fixture.home, "README.md"), "# ACP content smoke\n")
const acp = fixture.spawn()
@@ -41,6 +41,16 @@ describe("acp prompt content subprocess", () => {
}),
)
const remote = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this remote image." },
{ type: "image", mimeType: "image/png", data: "", uri: "https://example.com/remote.png" },
],
}),
)
const linked = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
@@ -70,8 +80,9 @@ describe("acp prompt content subprocess", () => {
}),
)
expect(remote.stopReason).toBe("end_turn")
expect(linked.stopReason).toBe("end_turn")
expect(missing.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(4)
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(5)
}, 60_000)
})
+232 -428
View File
@@ -1,27 +1,32 @@
import { describe, expect, test } from "bun:test"
import type { StopReason } from "@agentclientprotocol/sdk"
import type { OpenCodeEvent } from "@opencode/client/promise"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
import { Schema } from "effect"
import { mkdir } from "node:fs/promises"
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,118 +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("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",
@@ -155,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,
@@ -169,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) => OpenCodeEvent[] }>([
{ name: "a normal stop", stopReason: "end_turn", events: (id) => [stepEnded(id, "msg"), succeeded(id)] },
{
name: "a length-limited step",
stopReason: "max_tokens",
events: (id) => [stepEnded(id, "msg", { finish: "length" }), succeeded(id)],
},
{
name: "a content-filtered step",
stopReason: "refusal",
events: (id) => [stepEnded(id, "msg", { finish: "content-filter" }), succeeded(id)],
},
{
name: "a content-filter failure",
stopReason: "refusal",
events: (id) => [failed(id, { type: "provider.content-filter", message: "blocked" })],
},
{
name: "a server-side interruption",
stopReason: "cancelled",
events: (id) => [durableEvent("session.execution.interrupted", { sessionID: id, reason: "shutdown" })],
},
])("maps $name to $stopReason", async (input) => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...input.events(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe(input.stopReason)
})
test("maps provider auth failures to auth required", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
failed(sessionID, { type: "provider.auth", message: "missing key" }),
],
})
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
})
test("maps an assistant 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 () => {
@@ -331,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",
@@ -390,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"),
],
})
@@ -415,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",
@@ -423,26 +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" } })
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 () => {
@@ -482,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])
})
})
@@ -623,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",
)
}
+2 -50
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import type { SessionMessage } from "@opencode/schema/session-message"
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
describe("acp session replay over the wire", () => {
@@ -59,57 +59,9 @@ describe("acp session replay over the wire", () => {
})
expect(updates[11]?.update).toMatchObject({ toolCallId: "call_streaming", status: "pending", rawInput: {} })
})
test("continues replay after one message fails to translate", async () => {
await using acp = await startWire({
fetch(request) {
if (request.path !== "/api/session/ses_replay_failure/message") return undefined
return Response.json({
data: [
replayToolMessage("call_first", { status: "error", input: {}, metadata: {} }),
replayToolMessage("call_after", {
status: "completed",
input: { command: "printf done" },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
}),
],
cursor: {},
})
},
})
acp.server.sessions.set("ses_replay_failure", makeSession("ses_replay_failure"))
await acp.initialize()
const loaded = await acp.request("session/load", {
cwd: "/workspace",
sessionId: "ses_replay_failure",
mcpServers: [],
})
expect(loaded.configOptions).toBeDefined()
expect(
acp.updates.flatMap((item) =>
item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
? [[item.update.toolCallId, item.update.sessionUpdate]]
: [],
),
).toEqual([
["call_first", "tool_call"],
["call_after", "tool_call"],
["call_after", "tool_call_update"],
])
})
})
function replayToolMessage(id: string, state: Record<string, unknown>) {
return {
...assistantMessage(`msg_${id}`),
content: [{ type: "tool", id, name: "shell", time: { created: 1, completed: 2 }, state }],
}
}
function replayFixtureMessages(): SessionMessageInfo[] {
function replayFixtureMessages(): Array<typeof SessionMessage.Info.Encoded> {
return [
{
id: "msg_user",
@@ -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 -211
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,131 +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("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: [] }
expect(await rpcError(acp.request("session/load", params))).toEqual({
code: -32602,
message: 'Invalid params: Expected a string starting with "ses"',
data: {},
})
expect(await rpcError(acp.request("session/fork", params))).toEqual({
code: -32602,
message: "Invalid params: Invalid session ID",
data: { field: "sessionID" },
})
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",
@@ -312,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([])
})
})
@@ -370,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
+38 -64
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)
@@ -161,7 +127,34 @@ export function expectOk<T>(response: JsonRpcResponse<T>) {
return response.result
}
function verifierConfig(llmUrl: string, skills?: string) {
/** A streamed completion that calls one tool. */
export function toolCallStream(id: string, name: string, input: unknown) {
const chunks = [
{
choices: [
{
delta: {
role: "assistant",
tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }],
},
finish_reason: null,
},
],
usage: null,
},
{
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(input) } }] } }],
usage: null,
},
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
]
return new Response(`${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`, {
headers: { "content-type": "text/event-stream" },
})
}
function verifierConfig(llmUrl: string) {
const model = {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: { input: 0, output: 0 },
@@ -170,7 +163,6 @@ function verifierConfig(llmUrl: string, skills?: string) {
return {
update: "disable",
model: "test/test-model",
...(skills ? { skills: [skills] } : {}),
providers: {
test: {
name: "Test",
@@ -281,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
@@ -351,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" },
})
})
})
+604 -117
View File
@@ -1,78 +1,194 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { OpenCodeEvent, type OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
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 = "ses_root"
const root = Session.ID.make("ses_root")
const ctx: ACPTranslate.Context = {
sessionID: root,
cwd: "/workspace",
start: { type: "input", id: "msg_input" },
start: { type: "input", id: SessionMessage.ID.make("msg_input") },
childUpdates: false,
compaction: false,
mode: "turn",
}
const decodeEvent = Schema.decodeUnknownSync(OpenCodeEvent)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
function run(events: ReadonlyArray<OpenCodeEvent>, 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, { ...event, id: `evt_${index + 1}` }, context)
return {
state: next.state,
outputs: [...acc.outputs, ...next.outputs],
...(next.terminal ? { terminal: next.terminal } : {}),
}
},
{ state, outputs: [] },
)
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 started(...events: OpenCodeEvent[]) {
return run([delivered(root, "msg_input"), ...events])
}
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,
@@ -81,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")],
@@ -153,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.abandonTools(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.abandonTools(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] : []))
}
+109 -395
View File
@@ -1,346 +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, "input_other"),
textDelta(sessionID, "msg_wrong_input", "wrong input"),
delivered(sessionID, id),
textDelta("ses_other", "msg_other", "other session"),
textDelta(sessionID, "msg_accepted", "accepted"),
stepEnded(sessionID, "msg_accepted"),
succeeded("ses_other"),
succeeded(sessionID),
],
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(turnUpdates(acp.updates)).toEqual([
{
sessionId: acp.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_accepted",
content: { type: "text", text: "accepted" },
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({
@@ -363,53 +146,10 @@ describe("acp turn events over the wire", () => {
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
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" }]],
expect(acp.server.cancelledForms).toEqual([
{ sessionID: acp.sessionId, formID: "frm_question", message: ACPElicitation.UnshownQuestionMessage },
])
expect(acp.elicitations).toEqual([])
})
})
@@ -418,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
}
+84 -105
View File
@@ -8,6 +8,7 @@ import {
type AgentRequestParamsByMethod,
type AgentRequestResponsesByMethod,
type AnyMessage,
type CompactionCapabilities,
type ContentBlock,
type CreateElicitationRequest,
type CreateElicitationResponse,
@@ -15,35 +16,42 @@ import {
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type WriteTextFileRequest,
} from "@agentclientprotocol/sdk"
import {
OpenCode,
type AgentInfo,
type CommandInfo,
type LocationRef,
type ModelInfo,
type ModelRef,
type OpenCodeEvent,
type SessionInfo,
type SessionMessageInfo,
type TokenUsageInfo,
} from "@opencode/client/promise"
import { OpenCode } from "@opencode/client/effect"
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
import type { Agent } from "@opencode/schema/agent"
import type { Command } from "@opencode/schema/command"
import { Form } from "@opencode/schema/form"
import type { Location } from "@opencode/schema/location"
import type { Model } from "@opencode/schema/model"
import type { Session } from "@opencode/schema/session"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { TokenUsage } from "@opencode/schema/token-usage"
import type { BunRequest } from "bun"
import { Duration, Effect, Exit, Logger, Option, Schema, Scope } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { ACP } from "../../src/acp/agent"
import { ACPTurn } from "../../src/acp/turn"
// The fake server sends and stores the wire form of each value.
type OpenCodeEvent = OpenCodeEventEncoded
type AgentInfo = typeof Agent.Info.Encoded
type CommandInfo = typeof Command.Info.Encoded
type LocationRef = typeof Location.PublicRef.Encoded
type ModelInfo = typeof Model.Info.Encoded
type ModelRef = typeof Model.Ref.Encoded
type SessionInfo = typeof Session.Info.Encoded
type SessionMessageInfo = typeof SessionMessage.Info.Encoded
type TokenUsageInfo = typeof TokenUsage.Info.Encoded
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
type EventData<Type extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { type: Type }>["data"]
type AssistantMessage = Extract<SessionMessageInfo, { type: "assistant" }>
export type Events = ReadonlyArray<OpenCodeEvent> | void
type 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>
@@ -80,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)
@@ -88,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 }
@@ -114,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>
@@ -125,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?: (
@@ -149,24 +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",
@@ -194,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: {} },
@@ -203,7 +201,7 @@ export const buildAgent = {
permissions: [],
} satisfies AgentInfo
export const planAgent = {
const planAgent = {
id: "plan",
name: "Plan",
description: "Plan first",
@@ -259,12 +257,16 @@ export function tokens(value = 1): TokenUsageInfo {
return { input: value, output: value, reasoning: 0, cache: { read: 0, write: 0 } }
}
// The fake server stamps ids and sequence numbers when it sends an event.
function durable<Version extends DurableEvent["durable"]["version"]>(version: Version) {
return <Type extends Extract<DurableEvent, { durable: { version: Version } }>["type"]>(
type: Type,
data: EventData<Type>,
) => ({ id: "", created: 0, type, durable: { aggregateID: "test", seq: 0, version }, data })
// The fake server stamps ids and sequence numbers when it sends an event. The wire form types every version as a
// number, so the client's decode is what rejects an event sent under the wrong version.
function durable(version: number) {
return <Type extends DurableEvent["type"]>(type: Type, data: EventData<Type>) => ({
id: "",
created: 0,
type,
durable: { aggregateID: "test", seq: 0, version },
data,
})
}
export const durableEvent = durable(1)
@@ -370,6 +372,7 @@ export function permissionAsked(
id: string,
input: {
readonly action?: string
readonly resources?: EventData<"permission.asked">["resources"]
readonly metadata?: EventData<"permission.asked">["metadata"]
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {},
@@ -378,12 +381,17 @@ export function permissionAsked(
id,
sessionID,
action: input.action ?? "shell",
resources: ["*"],
resources: input.resources ?? ["*"],
metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}),
})
}
/** The response to a submission the server admitted to the session inbox. */
export function enqueued(sessionID: string, id: string, type: string, payload: object) {
return Response.json({ data: { id, sessionID, time: { created: 0 }, type, payload, delivery: "steer" } })
}
export async function startWire(options: WireOptions = {}) {
const waiters = new Set<() => void>()
const changed = () => waiters.forEach((check) => check())
@@ -392,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 }
@@ -408,10 +415,9 @@ export async function startWire(options: WireOptions = {}) {
const logs: Array<Pick<Logger.Options<unknown>, "message" | "cause">> = []
const agentScope = Scope.makeUnsafe()
const agentConnection = await Effect.runPromise(
ACP.connect(
OpenCode.make({ baseUrl: server.url }),
ndJsonStream(agentToClient.writable, clientToAgent.readable),
).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) =>
options.cancelDrainTimeout === undefined
@@ -434,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(
@@ -488,8 +490,8 @@ 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: {
...(capabilities.childSessionUpdates ? { "opencode/child-session-updates": true } : {}),
...(capabilities.terminalAuth ? { "terminal-auth": true } : {}),
@@ -500,13 +502,12 @@ export async function startWire(options: WireOptions = {}) {
return {
server,
logs,
received,
updates,
permissions,
writes,
childUpdates,
elicitations,
logs,
request,
until,
initialize,
@@ -536,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>) {
@@ -559,19 +560,17 @@ 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[] = []
const interrupts: string[] = []
const replies: Array<{ readonly sessionID: string; readonly requestID: string; readonly decision: string }> = []
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string }> = []
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string; readonly message?: string }> = []
const repliedForms: FormReply[] = []
const mcp: Array<{ readonly name: string; readonly directory?: string; readonly config: unknown }> = []
const fake = {
requests,
catalog,
catalogReads,
sessions: new Map<string, SessionInfo>(),
messages: new Map<string, SessionMessageInfo[]>(),
submissions,
@@ -611,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>) => {
@@ -651,7 +641,8 @@ function startServer(options: WireOptions, changed: () => void) {
schema: Schema.Codec<A, unknown>,
handle: (req: BunRequest<Path>, body: A, query: Record<string, string>) => Response | Promise<Response>,
) {
const decode = Schema.decodeUnknownOption(Schema.fromJsonString(schema))
// The JSON codec reads an explicit null as an absent optional field, as the server's does.
const decode = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.toCodecJson(schema)))
return (req: BunRequest<Path>) =>
record(req)
.then((recorded) => {
@@ -662,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)
@@ -709,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()]
@@ -732,28 +716,22 @@ 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)
if (input.permissions) session.permissions = [...input.permissions]
if (input.metadata) session.metadata = input.metadata
fake.sessions.set(session.id, {
...session,
...(input.permissions ? { permissions: input.permissions } : {}),
...(input.metadata ? { metadata: input.metadata } : {}),
})
return noContent()
}),
},
"/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)
@@ -776,19 +754,13 @@ 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
fake.submissions.push({ kind: "prompt", sessionID, ...input })
const hook = options.onPrompt ?? (() => turn(sessionID, input.id))
await emit(hook({ sessionID, id: input.id, text: input.text, signal: req.signal }))
return Response.json({ data: { text: input.text } })
return enqueued(sessionID, input.id, "user", { text: input.text })
}),
},
"/api/session/:sessionID/command": {
@@ -801,13 +773,13 @@ function startServer(options: WireOptions, changed: () => void) {
POST: body(CompactBody, (req, input) => {
fake.submissions.push({ kind: "compact", sessionID: req.params.sessionID, ...input })
fake.send(...turn(req.params.sessionID, input.id))
return Response.json({ data: {} })
return enqueued(req.params.sessionID, input.id, "compaction", {})
}),
},
"/api/session/:sessionID/synthetic": {
POST: body(SyntheticBody, (req, input) => {
fake.submissions.push({ kind: "synthetic", sessionID: req.params.sessionID, ...input })
return Response.json({ data: {} })
return enqueued(req.params.sessionID, "msg_synthetic", "synthetic", { text: input.text })
}),
},
"/api/session/:sessionID/interrupt": {
@@ -819,16 +791,18 @@ 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()
}),
},
"/api/session/:sessionID/form/:formID": {
DELETE: route(async (req) => {
const form = { sessionID: req.params.sessionID, formID: req.params.formID }
DELETE: route(async (req, query) => {
const form = { sessionID: req.params.sessionID, formID: req.params.formID, message: query.message }
fake.cancelledForms.push(form)
await emit(options.onFormCancel?.(form))
return noContent()
@@ -858,6 +832,11 @@ function startServer(options: WireOptions, changed: () => void) {
streams.forEach((stream) => stream.close())
streams.clear()
},
/** Drops the connection of every open event stream while the server keeps answering requests. */
dropEvents() {
streams.forEach((stream) => stream.error())
streams.clear()
},
async stop() {
streams.forEach((stream) => stream.close())
streams.clear()
+2 -2
View File
@@ -21,9 +21,9 @@ await Effect.runPromise(
record("method")
return Updater.methods.find((method) => method === (process.env.UPGRADE_TEST_METHOD ?? "npm"))
}),
latest: () =>
latest: (method) =>
Effect.suspend(() => {
record("latest")
record({ latest: method })
return process.env.UPGRADE_TEST_LATEST_ERROR
? Effect.fail(new Error("Update check failed"))
: Effect.succeed("0.0.0-beta-new")
@@ -15,6 +15,16 @@ describe("CLI frontend import boundaries", () => {
expect(imports).toEqual([])
})
test("keeps ACP and its tests off the promise client", async () => {
const glob = new Bun.Glob("{src,test}/acp/**/*.ts")
const imports: string[] = []
for await (const file of glob.scan({ cwd: path.join(root, "packages/cli") })) {
const source = await Bun.file(path.join(root, "packages/cli", file)).text()
if (/["']@opencode\/client(?:\/promise(?:\/[^"']*)?|\/service)?["']/.test(source)) imports.push(file)
}
expect(imports).toEqual([])
})
test("exposes only the intentional package entrypoints", async () => {
const run = await import("@opencode/cli/run")
const mini = await import("@opencode/tui/mini")
+1
View File
@@ -283,6 +283,7 @@ test("concurrent service processes elect one server", async () => {
urls: [info.url],
// The server reports the canonical tmp directory; Windows os.tmpdir() can be an 8.3 short name.
paths: { tmp: await fs.realpath(path.join(os.tmpdir(), "opencode")) },
capabilities: { persistentPty: process.platform !== "win32" },
})
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
+51 -9
View File
@@ -22,6 +22,7 @@ function fixture(
name = "@opencode/cli",
failCleanup = false,
releasePackage = name,
formula?: string,
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -29,20 +30,30 @@ function fixture(
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const execPath = process.execPath
const modules = path.join(root, "node_modules")
const executable = path.join(modules, "@opencode", "cli", "bin", "opencode")
const executable = formula
? path.join(root, "Cellar", formula, "2.0.20", "bin", "opencode")
: path.join(modules, "@opencode", "cli", "bin", "opencode")
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
yield* fs.writeFileString(executable, "binary")
yield* fs.writeFileString(
path.join(modules, "@opencode", "cli", "package.json"),
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
)
if (!formula)
yield* fs.writeFileString(
path.join(modules, "@opencode", "cli", "package.json"),
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
)
const requests: string[] = []
// The updater uses global fetch; scope this replacement to each install test.
yield* Effect.acquireRelease(
Effect.sync(() =>
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: releasePackage } }), {
preconnect: fetch.preconnect,
}),
Object.assign(
async (input: string | URL | Request) => {
const url = input instanceof Request ? input.url : input.toString()
requests.push(url)
if (new URL(url).hostname === "formulae.brew.sh") return Response.json({ versions: { stable: "2.0.21" } })
return Response.json({ version: "2.3.4", metadata: { package: releasePackage } })
},
{ preconnect: fetch.preconnect },
),
),
),
(request) => Effect.sync(() => request.mockRestore()),
@@ -106,7 +117,7 @@ function fixture(
}),
),
)
return { updater, commands, global, fs, executable }
return { updater, commands, requests, global, fs, executable }
})
}
@@ -281,6 +292,37 @@ it.live("vp detection ignores no-match output that repeats the package name", ()
}),
)
it.live("Homebrew Core installs check and upgrade the Core formula", () =>
Effect.gen(function* () {
const test = yield* fixture(() => ({}), "@opencode/cli", false, "anomalyco/tap/opencode-v2", "opencode")
expect(yield* test.updater.method()).toBe("brew")
expect(yield* test.updater.latest()).toBe("2.0.21")
yield* test.updater.upgrade("brew", "2.0.21")
expect(test.commands).toEqual([["brew", "upgrade", "opencode"]])
// An explicitly selected method keeps its own release source.
expect(yield* test.updater.latest("npm")).toBe("2.3.4")
expect(test.requests).toEqual([
"https://formulae.brew.sh/api/formula/opencode.json",
"https://opencode.ai/update/api/local/cli/npm?current=local",
])
}),
)
;["opencode-v2", "opencode-beta"].forEach((formula) => {
it.live(`Homebrew tap installs of ${formula} use the published tap formula`, () =>
Effect.gen(function* () {
const test = yield* fixture(() => ({}), "@opencode/cli", false, `anomalyco/tap/${formula}`, formula)
expect(yield* test.updater.method()).toBe("brew")
expect(yield* test.updater.latest()).toBe("2.3.4")
yield* test.updater.upgrade("brew", "2.3.4")
expect(test.commands).toEqual([["brew", "upgrade", `anomalyco/tap/${formula}`]])
expect(test.requests).toEqual([
"https://opencode.ai/update/api/local/cli/homebrew?current=local",
"https://opencode.ai/update/api/local/cli/homebrew?current=local",
])
}),
)
})
// Links are named opencode-upgrade-<pid>-<random>.exe; read them from inside the installer run.
const links = (directory: string) =>
existsSync(directory) ? readdirSync(directory).filter((name) => name.startsWith("opencode-")) : []
+8 -2
View File
@@ -18,10 +18,16 @@ describe("upgrade command", () => {
test("detects the installation method and resolves the latest version", async () => {
const result = await cli([])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual(["method", "latest", { method: "npm", version: "0.0.0-beta-new" }])
expect(result.events).toEqual(["method", { latest: "npm" }, { method: "npm", version: "0.0.0-beta-new" }])
expect(result.stdout).toContain("Upgrade complete")
})
test("resolves the latest version for an explicit method without detection", async () => {
const result = await cli(["--method", "pnpm"], { UPGRADE_TEST_METHOD: "brew" })
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ latest: "pnpm" }, { method: "pnpm", version: "0.0.0-beta-new" }])
})
test("accepts an explicit version and method without detection or a version lookup", async () => {
const result = await cli(["v0.0.0-beta-target", "--method", "pnpm"])
expect(result.exitCode).toBe(0)
@@ -64,7 +70,7 @@ describe("upgrade command", () => {
test("reports version lookup failures without installing", async () => {
const result = await cli([], { UPGRADE_TEST_LATEST_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method", "latest"])
expect(result.events).toEqual(["method", { latest: "npm" }])
expect(result.stdout).toContain("Update check failed")
})
+9
View File
@@ -44,6 +44,7 @@ export type ServerInfoOutput = {
readonly pid: number
readonly urls: ReadonlyArray<string>
readonly paths: { readonly tmp: string }
readonly capabilities?: { readonly persistentPty?: boolean | undefined } | undefined
}
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
@@ -2270,6 +2271,13 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type VcsInitInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}
export type VcsInitOutput = void
export type VcsInitOperation<E = never> = (input?: VcsInitInput) => Effect.Effect<VcsInitOutput, E>
export type VcsGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type VcsGetOutput = { readonly location: Location.PublicRef; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
@@ -2300,6 +2308,7 @@ export type VcsDiffOutput = { readonly location: Location.PublicRef; readonly da
export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect<VcsDiffOutput, E>
export interface VcsApi<E = never> {
readonly init: VcsInitOperation<E>
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
@@ -242,6 +242,8 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsInitInput,
VcsInitOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -1481,6 +1483,13 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
refresh: EndpointWorktreeRefresh(raw),
})
const EndpointVcsInit = (raw: RawClient["server.vcs"]) => (input?: VcsInitInput) =>
preserveEffect<VcsInitOutput>()(
raw["vcs.init"]({ query: { location: input?.["location"], provider: input?.["provider"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
preserveEffect<VcsGetOutput>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1511,6 +1520,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
init: EndpointVcsInit(raw),
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
@@ -240,6 +240,8 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsInitInput,
VcsInitOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -2040,6 +2042,18 @@ export function make(options: ClientOptions) {
),
},
vcs: {
init: (input?: VcsInitInput, requestOptions?: RequestOptions) =>
request<VcsInitOutput>(
{
method: "POST",
path: `/api/vcs/init`,
query: { location: input?.["location"], provider: input?.["provider"] },
successStatus: 204,
declaredStatuses: [400, 401, 409, 501, 503],
empty: true,
},
requestOptions,
),
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
+28 -1
View File
@@ -1,6 +1,12 @@
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
export type ServerInfo = {
version: string
pid: number
urls: Array<string>
paths: { tmp: string }
capabilities?: { persistentPty?: boolean | undefined } | undefined
}
export type PairingCode = { code: string; expires_in: number }
@@ -2742,6 +2748,14 @@ export type WorktreeError = {
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "WorktreeError"
export type VcsInitNotSupportedError = {
readonly _tag: "VcsInitNotSupportedError"
readonly providerID: string
readonly message: string
}
export const isVcsInitNotSupportedError = (value: unknown): value is VcsInitNotSupportedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "VcsInitNotSupportedError"
export type ServerInfoOutput = ServerInfo
export type ServerPairOutput = PairingCode
@@ -6495,6 +6509,19 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
export type WorktreeRefreshOutput = void
export type VcsInitInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}["location"]
readonly provider?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}["provider"]
}
export type VcsInitOutput = void
export type VcsGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+2
View File
@@ -57,6 +57,7 @@ const PACKAGES: Readonly<Record<string, string>> = {
"@ai-sdk/anthropic": "@opencode/ai/providers/anthropic",
"@ai-sdk/azure": "@opencode/ai/providers/azure/responses",
"@ai-sdk/cerebras": "@opencode/ai/providers/cerebras",
"@ai-sdk/cohere": "@opencode/ai/providers/cohere",
"@ai-sdk/deepinfra": "@opencode/ai/providers/deepinfra",
"@ai-sdk/google": "@opencode/ai/providers/google",
"@ai-sdk/google-vertex": "@opencode/ai/providers/google-vertex",
@@ -91,6 +92,7 @@ const HOSTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
"@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-ai-gateway",
"ai-gateway-provider": "@opencode/ai/providers/cloudflare-ai-gateway",
},
cohere: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/cohere/chat" },
"cloudflare-workers-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-workers-ai" },
deepseek: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/deepseek" },
digitalocean: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/digitalocean" },
+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 = (
+19 -1
View File
@@ -1,6 +1,8 @@
export * as ConfigFormatterPlugin from "./formatter.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { Entry } from "@opencode/schema/config"
import type { ConfigFormatter } from "@opencode/schema/config/formatter"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
@@ -25,7 +27,7 @@ export const Plugin = define({
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
yield* formatter.transform((editor) => {
const configured = Config.latest(loaded.entries, "formatter")
const configured = resolve(loaded.entries)
if (!configured) return
const builtIns = make({
directory: location.directory,
@@ -56,3 +58,19 @@ export const Plugin = define({
})
}),
})
// Documents apply from lowest to highest priority. A boolean resets everything before it; an object
// merges each named formatter's supplied fields over the same name from earlier objects.
function resolve(entries: readonly Entry[]) {
return entries.reduce<boolean | Record<string, ConfigFormatter.Entry> | undefined>((result, entry) => {
if (entry.type !== "document" || entry.info.formatter === undefined) return result
if (typeof entry.info.formatter === "boolean") return entry.info.formatter
return Object.entries(entry.info.formatter).reduce(
(merged, [name, item]) => ({
...merged,
[name]: { ...merged[name], ...item, environment: { ...merged[name]?.environment, ...item.environment } },
}),
typeof result === "object" ? result : {},
)
}, undefined)
}
+11 -4
View File
@@ -10,6 +10,7 @@ const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
"disable-model-invocation": Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
@@ -22,12 +23,16 @@ const metadataBoolean = (metadata: unknown, key: string) => {
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const value = (metadata as Record<string, unknown>)[key]
return booleanValue((metadata as Record<string, unknown>)[key])
}
const booleanValue = (value: unknown) => {
if (typeof value === "boolean") return value
if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (normalized === "true") return true
if (normalized === "false") return false
if (["true", "yes", "on", "1"].includes(normalized)) return true
if (["false", "no", "off", "0"].includes(normalized)) return false
return undefined
}
@@ -41,7 +46,9 @@ export function parse(directory: string, filepath: string, content: string): Par
path.dirname(filepath) === directory && path.basename(filepath) !== "SKILL.md"
? path.basename(filepath, ".md")
: path.basename(path.dirname(filepath))
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
const opencodeAutoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
const disableModelInvocation = booleanValue(frontmatter["disable-model-invocation"])
const autoinvoke = opencodeAutoinvoke ?? (disableModelInvocation ? false : undefined)
return {
_tag: "Parsed",
skill: {
-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"
+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
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-context automation","famLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
@@ -3,6 +3,9 @@ import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promise
import path from "node:path"
import asset from "./pty-binding.js"
// opencode-pty does not ship Windows binaries.
export const available = process.platform !== "win32"
export async function resolveBinary(bin: string) {
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
if (!asset) return "opencode-pty"
@@ -1,3 +1,6 @@
// opencode-pty does not ship Windows binaries.
export const available = process.platform !== "win32"
export async function resolveBinary() {
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
}
@@ -1,3 +1,5 @@
export const available = false
export async function resolveBinary(): Promise<string> {
throw new Error("Persistent PTYs are unavailable in this runtime")
}
@@ -22,6 +22,7 @@ import { resolveBinary } from "#persistent-pty-binary"
export type { Role, StreamEvent } from "./daemon.js"
export { Handoff } from "@opencode/schema/persistent-pty"
export { available } from "#persistent-pty-binary"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
+9 -1
View File
@@ -23,7 +23,8 @@ export const Plugin = define({
id: "opencode.vcs.git",
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
// Markerless locations need this definition to initialize their first repository.
if (location.vcs && location.vcs.type !== "git") return
const processes = yield* AppProcess.Service
const adapter = make(processes, {
@@ -35,6 +36,13 @@ export const Plugin = define({
editor.add({
id: "git",
name: "Git",
init: (input) =>
Effect.gen(function* () {
const result = yield* processes.run(
ChildProcess.make(gitExecutable, ["init"], { cwd: input.worktree, stdin: "ignore" }),
)
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Git initialization failed"))
}),
info: () => adapter.info(),
base: () => adapter.base(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
+14 -1
View File
@@ -26,7 +26,8 @@ export const Plugin = define({
id: "opencode.vcs.hg",
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
// Markerless locations need this definition to initialize their first repository.
if (location.vcs && location.vcs.type !== "hg") return
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
@@ -39,6 +40,18 @@ export const Plugin = define({
editor.add({
id: "hg",
name: "Mercurial",
init: (input) =>
Effect.gen(function* () {
const result = yield* processes.run(
ChildProcess.make("hg", ["init"], {
cwd: input.worktree,
env: { HGPLAIN: "1" },
extendEnv: true,
stdin: "ignore",
}),
)
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Mercurial initialization failed"))
}),
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
+2
View File
@@ -67,6 +67,8 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/cerebras", () => import("@opencode/ai/providers/cerebras")],
["@opencode/ai/providers/cloudflare-ai-gateway", () => import("@opencode/ai/providers/cloudflare-ai-gateway")],
["@opencode/ai/providers/cloudflare-workers-ai", () => import("@opencode/ai/providers/cloudflare-workers-ai")],
["@opencode/ai/providers/cohere", () => import("@opencode/ai/providers/cohere")],
["@opencode/ai/providers/cohere/chat", () => import("@opencode/ai/providers/cohere/chat")],
["@opencode/ai/providers/deepinfra", () => import("@opencode/ai/providers/deepinfra")],
["@opencode/ai/providers/deepseek", () => import("@opencode/ai/providers/deepseek")],
["@opencode/ai/providers/digitalocean", () => import("@opencode/ai/providers/digitalocean")],
+42 -55
View File
@@ -129,48 +129,53 @@ const mimeToModality = (mime: string) => {
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: Model.Capabilities) => {
const modality = mimeToModality(mime)
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
return {
type: "text" as const,
text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`,
}
return `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`
}
// Remote and provider-referenced media carry no local payload and never count toward the inline budget.
const mediaBytes = (media: Media.Asset) => {
if (media.source.type === "base64") return Buffer.byteLength(media.source.data)
if (media.source.type === "bytes") return Math.ceil(media.source.data.byteLength / 3) * 4
return 0
}
/** Replaces media with the returned text; messages without replacements are returned unchanged. */
const replaceMedia = (
messages: LLMRequest["messages"],
replace: (media: { mime: string; name: string | undefined; bytes: () => number }) => string | undefined,
) =>
messages.map((message) => {
const content = message.content.map((part) => {
if (part.type === "media") {
const text = replace({ mime: part.media.mediaType, name: part.filename, bytes: () => mediaBytes(part.media) })
return text === undefined ? part : Message.text(text)
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
const result = part.result
const value = result.value.map((item): Content => {
if (item.type !== "file") return item
const text = replace({ mime: item.mime, name: item.name, bytes: () => Buffer.byteLength(item.uri) })
return text === undefined ? item : { type: "text", text }
})
return value.every((item, index) => item === result.value[index])
? part
: { ...part, result: { ...result, value } }
})
return content.every((part, index) => part === message.content[index])
? message
: new Message({ ...message, content })
})
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: Model.Capabilities) =>
messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (part.type === "media") {
return unsupportedMedia(part.media.mediaType, part.filename, capabilities) ?? part
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
return {
...part,
result: {
...part.result,
value: part.result.value.map((item: Content) => {
if (item.type !== "file") return item
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
}),
},
}
}),
}),
)
replaceMedia(messages, (media) => unsupportedMedia(media.mime, media.name, capabilities))
export const boundImages = (messages: LLMRequest["messages"]) => {
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
// Remote and provider-referenced media carry no local payload and never count toward the inline budget.
const size = (media: Media.Asset) => {
if (media.source.type === "base64") return Buffer.byteLength(media.source.data)
if (media.source.type === "bytes") return Math.ceil(media.source.data.byteLength / 3) * 4
return 0
}
const imageBytes = messages.reduce(
(total, message) =>
total +
message.content.reduce((sum, part) => {
if (part.type === "media" && isImage(part.media.mediaType)) return sum + size(part.media)
if (part.type === "media" && isImage(part.media.mediaType)) return sum + mediaBytes(part.media)
if (part.type !== "tool-result" || part.result.type !== "content") return sum
return (
sum +
@@ -186,29 +191,11 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
let removed = 0
return messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (part.type === "media" && isImage(part.media.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
removed += size(part.media)
return Message.text(IMAGE_REMOVED)
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
return {
...part,
result: {
...part.result,
value: part.result.value.map((item: Content) => {
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item
removed += Buffer.byteLength(item.uri)
return { type: "text" as const, text: IMAGE_REMOVED }
}),
},
}
}),
}),
)
return replaceMedia(messages, (media) => {
if (!isImage(media.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return
removed += media.bytes()
return IMAGE_REMOVED
})
}
type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
+4 -2
View File
@@ -6,8 +6,8 @@ import { Context, Duration, Effect, Layer, Schedule } from "effect"
import { makeGlobalNode, makeLocationNode } from "@opencode/util/effect/app-node"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { Identifier } from "@opencode/schema/identifier"
import { FileRetention } from "./file-retention.js"
import { Identifier } from "./id/id.js"
import { State } from "./state.js"
export const MAX_LINES = 2_000
@@ -15,6 +15,8 @@ export const MAX_BYTES = 50 * 1024 // 50 KiB
export const RETENTION = Duration.days(7)
export const DIRECTORY = "tool-output"
export const fileName = (timestamp?: number) => "tool_" + Identifier.create(false, timestamp)
type Result = Tool.NormalizedResult
type Limits = {
@@ -81,7 +83,7 @@ const layer = Layer.effect(
kept.push(line)
bytes += size
}
const file = path.join(directory, Identifier.ascending("tool"))
const file = path.join(directory, fileName())
yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const shown = kept.length > 0 ? `lines 1-${kept.length}` : "0 lines"
+2 -1
View File
@@ -572,6 +572,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/baseten": basetenChat,
"@opencode/ai/providers/cerebras": openaiChat,
"@opencode/ai/providers/cloudflare-workers-ai": workersAIChat,
"@opencode/ai/providers/cohere/chat": openaiChat,
"@opencode/ai/providers/deepinfra": deepinfraChat,
"@opencode/ai/providers/deepseek": deepseekChat,
"@opencode/ai/providers/digitalocean": openaiChat,
@@ -598,11 +599,11 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/google-vertex": gemini,
"@opencode/ai/providers/amazon-bedrock": bedrockConverse,
"@opencode/ai/providers/cohere": cohere,
"@opencode/ai/providers/openrouter": openrouter,
[Provider.aisdk("venice-ai-sdk-provider")]: openaiChat,
"@opencode/ai/providers/cloudflare-ai-gateway": cloudflareAIGateway,
[Provider.aisdk("@ai-sdk/gateway")]: vercelGateway,
[Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sapAICore,
[Provider.aisdk("@ai-sdk/cohere")]: cohere,
}
+27 -1
View File
@@ -10,6 +10,7 @@ import { VcsEvent } from "@opencode/schema/vcs-event"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { FSUtil } from "@opencode/util/fs-util"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
@@ -20,6 +21,10 @@ export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError",
message: Schema.String,
}) {}
export class InitializeError extends Schema.TaggedError<InitializeError>()("Vcs.InitializeError", {
kind: Schema.Literals(["missing", "conflict", "unknown", "unsupported", "failed"]),
}) {}
export interface DiffOptions {
readonly context?: number
readonly base?: string
@@ -40,6 +45,7 @@ export interface Adapter {
export interface Interface extends Adapter, State.Transformable<VcsEditor> {
readonly base: () => Effect.Effect<Base | null, DiffError>
readonly initialize: (providerID: string) => Effect.Effect<void, InitializeError>
}
interface Data {
@@ -124,7 +130,9 @@ const layer = Layer.effect(
const provider = selected()
const next: Info = provider
? {
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })),
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), {
branch: {},
})),
provider: provider.id,
}
: { branch: {} }
@@ -166,6 +174,24 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
initialize: Effect.fn("Vcs.initialize")(function* (providerID: string) {
if (!(yield* fs.isDir(location.project.directory))) return yield* new InitializeError({ kind: "missing" })
if (yield* Project.root(fs, location.project.directory)) return yield* new InitializeError({ kind: "conflict" })
const provider = state.get().providers.get(providerID)
if (!provider) return yield* new InitializeError({ kind: "unknown" })
if (!provider.init) return yield* new InitializeError({ kind: "unsupported" })
yield* provider
.init(scope)
.pipe(
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(cause).pipe(Effect.orDie)
: Effect.logWarning("vcs initialization failed", { provider: providerID, cause }).pipe(
Effect.andThen(Effect.fail(new InitializeError({ kind: "failed" }))),
),
),
)
}),
info: Effect.fn("Vcs.info")(function* () {
return current.info
}),
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more