mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-30 20:47:39 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f46fa72a92 | ||
|
|
14636b9900 | ||
|
|
b020f1018b | ||
|
|
7878744505 | ||
|
|
599bdc982e | ||
|
|
82bb5ff88a | ||
|
|
0159356be6 | ||
|
|
9388b94a97 | ||
|
|
02c3891d9b | ||
|
|
1844c11104 | ||
|
|
c0285b457a |
@@ -599,8 +599,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.13",
|
||||
"@opentui/solid": ">=0.5.13",
|
||||
"@opentui/core": ">=0.5.14",
|
||||
"@opentui/solid": ">=0.5.14",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1117,9 +1117,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.13",
|
||||
"@opentui/keymap": "0.5.13",
|
||||
"@opentui/solid": "0.5.13",
|
||||
"@opentui/core": "0.5.14",
|
||||
"@opentui/keymap": "0.5.14",
|
||||
"@opentui/solid": "0.5.14",
|
||||
"@pierre/diffs": "1.5.1",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2255,25 +2255,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.13", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.13", "@opentui/core-darwin-x64": "0.5.13", "@opentui/core-linux-arm64": "0.5.13", "@opentui/core-linux-arm64-musl": "0.5.13", "@opentui/core-linux-x64": "0.5.13", "@opentui/core-linux-x64-musl": "0.5.13", "@opentui/core-win32-arm64": "0.5.13", "@opentui/core-win32-x64": "0.5.13" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-3XNXJx3J9I+Ce170oh7GJ3leBZZA7QYv4OAhwiMCPQtKMkhy3Wra99NM26HXcEGme0zjkkJLownwLLnxuc0U/Q=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.14", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5X9U2v5qn4+vKj31zpn0RMoBV3Q9ttdM4t5KN41NPg6XgtIOZgKwox5yOsvBaiQ1KGbuk5wMvXCxfWJ30Mj/nw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-9IgQHLKSn3quVsU0gibkRjOPegv1QH4573KWYXQNDmSxBXw84Nd1mDNsbK9f72SblAILhOCYK685pmk3X/z0Ig=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-jMXGEpB/2Ch/IR0QzYBM5eCr1tUzicxoTP1AbMCbOduJXcGRCHTWqIdQIgRErwPF92gBcDIs4JugSn/Jd0Ki7g=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-7ytcKy7pQxvQer3PADGYNK+HRhSDyhZvp475GRBEhlBJIdhdL8PNU6hjd2R1dnieifiW7Mpc4EiC1kGKe69gYg=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-C2ZC7WA7YH/GS+rJnbeVGe6Kxeor0aTD2qcS6l32KmN4XuY7TXm192XJMfxxnKaTA6oKp3mW27ezolJKUcGtMw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-IB6eLh6I/gYXrJ0AYxZpD+Dr+yfZJck0ZfLnIapbD+fykaT0umOtvc/BL+CtlC/aSc7jpe0q4eXdn6XIA8HtLw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.13", "", { "os": "win32", "cpu": "x64" }, "sha512-FCucDN8A4iFDPdwqLK99ISs42pcbcObkxhN1jtsnmcYM9/Ptron7Hhapx2ObQWYjSrJNnntmfZQd2ewpE8YqTQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.13", "", { "dependencies": { "@opentui/core": "0.5.13" }, "peerDependencies": { "@opentui/react": "0.5.13", "@opentui/solid": "0.5.13", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-bbpQ5N8000CuvVDhptGuHxUE92sivChjUjLkvYT1VnLlDTiGbQ4mPsHfnqsaEokc7O7jBcoJXJQsLxw7rO/qkg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.13", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.13", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-MGMYQYeoZNn9uq3zsLPqq/G+nBQXgGwpylsrYux0nJ+C+6oflDEE6t8huSTZaAUizC/3WG8sVSVFutczeRbnPg=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.14", "", { "dependencies": { "@opentui/core": "0.5.14" }, "peerDependencies": { "@opentui/react": "0.5.14", "@opentui/solid": "0.5.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YGTAvRrpQTbRNV7GH0UxRuCSPxwnSooVdB+qPHHP+3ywc93+lhekljgP8dRivl32f3CiMqqm96Uj2PgwKxkC0Q=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-bBRl34mZ0wFGhjHX6y1VNiZmJ3DSm2DVPm0PNSVDpvq9qxRHuywEPTCn/Lgte0C9450tgf11cDqcE3pR9cZwVA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+3
-3
@@ -52,9 +52,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.13",
|
||||
"@opentui/keymap": "0.5.13",
|
||||
"@opentui/solid": "0.5.13",
|
||||
"@opentui/core": "0.5.14",
|
||||
"@opentui/keymap": "0.5.14",
|
||||
"@opentui/solid": "0.5.14",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -21,6 +21,8 @@ const patterns = [
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
/input token count.*exceeds the maximum/i,
|
||||
// Amazon Nova on Bedrock reports this as a mid-stream validationException.
|
||||
/number of input tokens exceeds maximum length/i,
|
||||
/tokens in request more than max tokens allowed/i,
|
||||
/maximum prompt length is \d+/i,
|
||||
/reduce the length of the messages/i,
|
||||
|
||||
@@ -42,14 +42,24 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(requireAccountId(input))}/ai/v1`
|
||||
}
|
||||
|
||||
// Cloudflare's REST API rejects parts of the Anthropic Messages and OpenAI Responses request shapes, such as
|
||||
// system prompt blocks and `minimal` reasoning effort, so these models use the provider-native gateway endpoints.
|
||||
const passthroughURL = (input: GatewayURL & GatewayOptions, provider: "anthropic/v1" | "openai") =>
|
||||
`https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(requireAccountId(input))}/${encodeURIComponent(gatewayId(input))}/${provider}`
|
||||
|
||||
const requireAccountId = (input: GatewayURL) => {
|
||||
if (input.accountId) return input.accountId
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
}
|
||||
|
||||
const gatewayId = (input: GatewayOptions) => input.gatewayId?.trim() || "default"
|
||||
|
||||
export const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "cloudflare-ai-gateway-responses",
|
||||
provider: id,
|
||||
@@ -70,16 +80,12 @@ export const route = OpenAIChat.route.with({
|
||||
|
||||
export const routes = [responsesRoute, messagesRoute, route]
|
||||
|
||||
const auth = (input: LanguageModelOptions) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
return Auth.optional(input.gatewayApiKey ?? ("apiKey" in input ? input.apiKey : undefined), "apiKey")
|
||||
const credential = (input: LanguageModelOptions) =>
|
||||
Auth.optional(input.gatewayApiKey ?? ("apiKey" in input ? input.apiKey : undefined), "apiKey")
|
||||
.orElse(Auth.config(authEnvVars[0]))
|
||||
.orElse(Auth.config(authEnvVars[1]))
|
||||
.bearer()
|
||||
}
|
||||
|
||||
const headers = (input: LanguageModelOptions) => ({
|
||||
...(input.gatewayId === undefined ? {} : { "cf-aig-gateway-id": input.gatewayId.trim() || "default" }),
|
||||
...(input.metadata === undefined
|
||||
? {}
|
||||
: { "cf-aig-metadata": Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(input.metadata) }),
|
||||
@@ -90,31 +96,42 @@ const headers = (input: LanguageModelOptions) => ({
|
||||
...input.headers,
|
||||
})
|
||||
|
||||
const modelID = (input: string | ModelID) => {
|
||||
const value = String(input)
|
||||
if (value.startsWith("workers-ai/")) return value.slice("workers-ai/".length)
|
||||
if (value.startsWith("anthropic/")) return `anthropic/${value.slice("anthropic/".length).replaceAll(".", "-")}`
|
||||
return value
|
||||
}
|
||||
|
||||
export const configure = (input: LanguageModelOptions) => {
|
||||
const defaults = {
|
||||
const custom = "auth" in input && input.auth ? input.auth : undefined
|
||||
const rest = {
|
||||
endpoint: { baseURL: baseURL(input) },
|
||||
auth: auth(input),
|
||||
headers: headers(input),
|
||||
auth: custom ?? credential(input).bearer(),
|
||||
headers: { "cf-aig-gateway-id": gatewayId(input), ...headers(input) },
|
||||
http: input.http,
|
||||
providerOptions: input.providerOptions,
|
||||
}
|
||||
const responses = responsesRoute.with(defaults)
|
||||
const messages = messagesRoute.with(defaults)
|
||||
const chat = route.with(defaults)
|
||||
// A custom baseURL stands in for the REST API, so every model keeps using it with gateway model IDs.
|
||||
const native = input.baseURL === undefined
|
||||
const passthrough = (provider: "anthropic/v1" | "openai") =>
|
||||
native
|
||||
? {
|
||||
...rest,
|
||||
endpoint: { baseURL: passthroughURL(input, provider) },
|
||||
auth: custom ?? Auth.bearerHeader("cf-aig-authorization", credential(input)),
|
||||
headers: headers(input),
|
||||
}
|
||||
: rest
|
||||
const responses = responsesRoute.with(passthrough("openai"))
|
||||
const messages = messagesRoute.with(passthrough("anthropic/v1"))
|
||||
const chat = route.with(rest)
|
||||
return {
|
||||
id,
|
||||
model: (input: string | ModelID) => {
|
||||
const wire = modelID(input)
|
||||
if (String(input).startsWith("openai/")) return responses.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
if (String(input).startsWith("anthropic/")) return messages.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
return chat.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
const value = String(input)
|
||||
if (value.startsWith("openai/"))
|
||||
return responses.model<OpenAIProviderOptionsInput>({ id: native ? value.slice("openai/".length) : value })
|
||||
if (value.startsWith("anthropic/"))
|
||||
return messages.model<OpenAIProviderOptionsInput>({
|
||||
id: native ? value.slice("anthropic/".length).replaceAll(".", "-") : value,
|
||||
})
|
||||
if (value.startsWith("workers-ai/"))
|
||||
return chat.model<OpenAIProviderOptionsInput>({ id: value.slice("workers-ai/".length) })
|
||||
return chat.model<OpenAIProviderOptionsInput>({ id: value })
|
||||
},
|
||||
configure,
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway-messages",
|
||||
"transport": "http",
|
||||
"model": "claude-haiku-4-5",
|
||||
"tags": [
|
||||
"prefix:cloudflare-ai-gateway",
|
||||
"provider:cloudflare-ai-gateway",
|
||||
"text",
|
||||
"golden"
|
||||
],
|
||||
"name": "cloudflare-ai-gateway/cloudflare-ai-gateway-claude-haiku-4-5-text",
|
||||
"recordedAt": "2026-09-30T04:35:43.657Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"p\": \"6247a7b95b5e3f\", \"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfZ16hXSVRfaXBDtUBKis\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}}\n\nevent: content_block_start\ndata: {\"p\": \"f9b\", \"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"p\": \"cc76aa8\", \"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"p\": \"94f\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"p\": \"1a9d8\", \"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"p\": \"0cb3c23\", \"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"p\": \"6ef9de29973ed\", \"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway-messages",
|
||||
"transport": "http",
|
||||
"model": "claude-haiku-4-5",
|
||||
"tags": [
|
||||
"prefix:cloudflare-ai-gateway",
|
||||
"provider:cloudflare-ai-gateway",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"golden"
|
||||
],
|
||||
"name": "cloudflare-ai-gateway/cloudflare-ai-gateway-claude-haiku-4-5-tool-loop",
|
||||
"recordedAt": "2026-09-30T04:35:45.222Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"p\": \"f2f6a212\", \"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfZ16jzmxrCrHLxeCxrL8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":593,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":26,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null} }\n\nevent: content_block_start\ndata: {\"p\": \"7\", \"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_019hb1DDwKBNMMeaKjZf1CpS\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"p\": \"e112b39d9a4869\", \"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"p\": \"12d\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"p\": \"2\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\"\"} }\n\nevent: content_block_delta\ndata: {\"p\": \"f6bb01e5ae5c1c7\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\": \\\"Pari\"} }\n\nevent: content_block_delta\ndata: {\"p\": \"c26aa17\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"s\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"p\": \"16f665\", \"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"p\": \"e00cc845875\", \"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":593,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":54} }\n\nevent: message_stop\ndata: {\"p\": \"5082e73ce8055a\", \"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_019hb1DDwKBNMMeaKjZf1CpS\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_019hb1DDwKBNMMeaKjZf1CpS\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"p\": \"ff048fa113d17ffc\", \"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfZ16ooU6seiMzxt69doW\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":668,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null} }\n\nevent: content_block_start\ndata: {\"p\": \"5172b1\", \"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"p\": \"d\", \"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"p\": \"7f847273c\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"} }\n\nevent: content_block_delta\ndata: {\"p\": \"1de4\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny\"}}\n\nevent: content_block_delta\ndata: {\"p\": \"d61\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"p\": \"29638a9b46e21ecd\", \"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"p\": \"8e5f9442b03f76\", \"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":668,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":7} }\n\nevent: message_stop\ndata: {\"p\": \"97d9afcead79c7a\", \"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+37
File diff suppressed because one or more lines are too long
+56
File diff suppressed because one or more lines are too long
@@ -366,6 +366,23 @@ describe("provider error rawBody classification", () => {
|
||||
expect(reason._tag === "InvalidRequest" ? reason.classification : reason._tag).toBe("context-overflow")
|
||||
})
|
||||
|
||||
test("classifies Amazon Nova input token rejections as context overflow", () => {
|
||||
const message =
|
||||
"The model returned the following errors: Input Tokens Exceeded: Number of input tokens exceeds maximum length. Please update the input to try again."
|
||||
const reason = classifyProviderFailure({
|
||||
message,
|
||||
rawBody: JSON.stringify({
|
||||
headers: {
|
||||
":exception-type": { type: "string", value: "validationException" },
|
||||
":content-type": { type: "string", value: "application/json" },
|
||||
":message-type": { type: "string", value: "exception" },
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
}),
|
||||
})
|
||||
expect(reason._tag === "InvalidRequest" ? reason.classification : reason._tag).toBe("context-overflow")
|
||||
})
|
||||
|
||||
test("classifies Google invalid API keys as authentication failures", () => {
|
||||
const rawBody = JSON.stringify({
|
||||
error: {
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.headers).toMatchObject({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ custom: true })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
}
|
||||
|
||||
@@ -39,9 +39,16 @@ describe("Cloudflare", () => {
|
||||
)
|
||||
|
||||
expect(responses.route).toBe("cloudflare-ai-gateway-responses")
|
||||
expect(responses.body).toMatchObject({ model: "openai/gpt-5.4", stream: true })
|
||||
expect(responses.model.route.endpoint.baseURL).toBe(
|
||||
"https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/openai",
|
||||
)
|
||||
expect(responses.body).toMatchObject({ model: "gpt-5.4", stream: true })
|
||||
expect(messages.route).toBe("cloudflare-ai-gateway-messages")
|
||||
expect(messages.body).toMatchObject({ model: "anthropic/claude-haiku-4-5" })
|
||||
expect(messages.model.route.endpoint.baseURL).toBe(
|
||||
"https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/anthropic/v1",
|
||||
)
|
||||
expect(messages.body).toMatchObject({ model: "claude-haiku-4-5" })
|
||||
expect(chat.model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1")
|
||||
expect(chat.route).toBe("cloudflare-ai-gateway-chat")
|
||||
expect(chat.body).toMatchObject({ model: "xai/grok-4.6", stream: true })
|
||||
expect(workers.route).toBe("cloudflare-ai-gateway-chat")
|
||||
@@ -100,6 +107,54 @@ describe("Cloudflare", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("posts Claude and OpenAI models to the provider-native gateway endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const gateway = CloudflareAIGateway.configure({ accountId: "test-account", apiKey: "test-token", cacheTtl: 300 })
|
||||
const requests: Array<Request> = []
|
||||
const capture = dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie))
|
||||
return input.respond("", { status: 500 })
|
||||
}),
|
||||
)
|
||||
yield* LLM.generate(LLM.request({ model: gateway.model("anthropic/claude-sonnet-4.6"), prompt: "Hi" })).pipe(
|
||||
Effect.provide(capture),
|
||||
Effect.ignore,
|
||||
)
|
||||
yield* LLM.generate(LLM.request({ model: gateway.model("openai/gpt-5-nano"), prompt: "Hi" })).pipe(
|
||||
Effect.provide(capture),
|
||||
Effect.ignore,
|
||||
)
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual([
|
||||
"https://gateway.ai.cloudflare.com/v1/test-account/default/anthropic/v1/messages",
|
||||
"https://gateway.ai.cloudflare.com/v1/test-account/default/openai/responses",
|
||||
])
|
||||
requests.forEach((request) => {
|
||||
expect(request.headers.get("cf-aig-authorization")).toBe("Bearer test-token")
|
||||
expect(request.headers.get("authorization")).toBeNull()
|
||||
expect(request.headers.get("x-api-key")).toBeNull()
|
||||
expect(request.headers.get("cf-aig-gateway-id")).toBeNull()
|
||||
expect(request.headers.get("cf-aig-cache-ttl")).toBe("300")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends the default gateway ID to the REST API", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: CloudflareAIGateway.configure({ accountId: "test-account", apiKey: "test-token" }).model(
|
||||
"workers-ai/@cf/meta/llama-3.3-70b-instruct",
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.model.route.defaults.headers).toMatchObject({ "cf-aig-gateway-id": "default" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves reasoning details for AI Gateway continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = CloudflareAIGateway.configure({
|
||||
@@ -160,12 +215,13 @@ describe("Cloudflare", () => {
|
||||
model: CloudflareAIGateway.configure({
|
||||
baseURL: "https://gateway.proxy.test/v1",
|
||||
apiKey: "test-token",
|
||||
}).model("xai/grok-4.6"),
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1")
|
||||
expect(prepared.body).toMatchObject({ model: "anthropic/claude-sonnet-4.6" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ const cloudflareWorkers = CloudflareWorkersAI.configure({
|
||||
})
|
||||
const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
|
||||
const cloudflareAIGatewayClaude = cloudflareAIGateway.model("anthropic/claude-haiku-4.5")
|
||||
const cloudflareAIGatewayOpenAI = cloudflareAIGateway.model("openai/gpt-5-nano")
|
||||
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
|
||||
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
|
||||
const deepseek = DeepSeek.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }).model("deepseek-chat")
|
||||
@@ -76,7 +78,7 @@ const openrouterOpus = OpenRouter.configure({
|
||||
const redactCloudflareURL = (url: string) =>
|
||||
url
|
||||
.replace(/\/client\/v4\/accounts\/[^/]+\/ai\/v1\//, "/client/v4/accounts/{account}/ai/v1/")
|
||||
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
||||
.replace(/\/v1\/[^/]+\/[^/]+\/(anthropic|openai)\//, "/v1/{account}/{gateway}/$1/")
|
||||
|
||||
const cloudflareOptions = {
|
||||
redact: { url: redactCloudflareURL },
|
||||
@@ -177,6 +179,25 @@ describeRecordedGoldenScenarios([
|
||||
options: cloudflareOptions,
|
||||
scenarios: [{ id: "tool-call", maxTokens: 120 }],
|
||||
},
|
||||
{
|
||||
name: "Cloudflare AI Gateway Claude Haiku 4.5",
|
||||
prefix: "cloudflare-ai-gateway",
|
||||
model: cloudflareAIGatewayClaude,
|
||||
requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
|
||||
options: { redact: { url: redactCloudflareURL, allowRequestHeaders: ["anthropic-version"] } },
|
||||
scenarios: ["text", "tool-loop"],
|
||||
},
|
||||
{
|
||||
name: "Cloudflare AI Gateway OpenAI GPT-5 Nano",
|
||||
prefix: "cloudflare-ai-gateway",
|
||||
model: cloudflareAIGatewayOpenAI,
|
||||
requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
|
||||
options: cloudflareOptions,
|
||||
scenarios: [
|
||||
{ id: "text", temperature: false, maxTokens: 2000 },
|
||||
{ id: "tool-loop", temperature: false, maxTokens: 2000, timeout: 30_000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Cloudflare Workers AI Llama 3.1 8B",
|
||||
prefix: "cloudflare-workers-ai",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { CommandInfo, ModelInfo, ModelRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
|
||||
export type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ModelInfo[]
|
||||
readonly defaultModel: ModelRef
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
readonly commands: CommandInfo[]
|
||||
}
|
||||
|
||||
export type Live = {
|
||||
readonly cwd: string
|
||||
current: Catalog
|
||||
}
|
||||
|
||||
// Provider, integration, and credential changes reach the catalog through model.updated.
|
||||
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
|
||||
|
||||
export function make(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly signal?: AbortSignal
|
||||
readonly changed: (live: Live, previous: Catalog) => Promise<unknown>
|
||||
}) {
|
||||
const entries = new Map<string, Promise<Live>>()
|
||||
const running = new Map<Live, Promise<void>>()
|
||||
const queued = new Set<Live>()
|
||||
let subscribed: Promise<void> | undefined
|
||||
|
||||
const get = (cwd: string) => {
|
||||
const key = FSUtil.resolve(cwd)
|
||||
const cached = entries.get(key)
|
||||
if (cached) return cached
|
||||
// Subscribe before the first read so an update between the read and the subscription is not lost.
|
||||
const loaded = subscribe()
|
||||
.then(() => load(input.client, cwd))
|
||||
.then((current): Live => ({ cwd, current }))
|
||||
.catch((error) => {
|
||||
entries.delete(key)
|
||||
throw error
|
||||
})
|
||||
entries.set(key, loaded)
|
||||
return loaded
|
||||
}
|
||||
|
||||
const reload = (live: Live) => {
|
||||
const current = running.get(live)
|
||||
if (current) {
|
||||
queued.add(live)
|
||||
return current
|
||||
}
|
||||
const run = (async () => {
|
||||
do {
|
||||
queued.delete(live)
|
||||
const next = await load(input.client, live.cwd).catch(() => undefined)
|
||||
if (!next) break
|
||||
const previous = live.current
|
||||
live.current = next
|
||||
await input.changed(live, previous).catch(() => {})
|
||||
} while (queued.has(live))
|
||||
running.delete(live)
|
||||
})()
|
||||
running.set(live, run)
|
||||
return run
|
||||
}
|
||||
|
||||
const subscribe = () =>
|
||||
(subscribed ??= new Promise<void>((ready) => {
|
||||
void (async () => {
|
||||
for await (const event of input.client.event.subscribe({ signal: input.signal })) {
|
||||
if (event.type === "server.connected") ready()
|
||||
if (!reloadOn.has(event.type)) continue
|
||||
const directory = event.location?.directory
|
||||
const targets = directory === undefined ? [...entries.values()] : [entries.get(FSUtil.resolve(directory))]
|
||||
targets.forEach((entry) => void entry?.then(reload, () => {}))
|
||||
}
|
||||
})()
|
||||
.catch(() => {})
|
||||
.finally(ready)
|
||||
}))
|
||||
|
||||
return { get, reload }
|
||||
}
|
||||
|
||||
async function load(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
// Some providers discover models in the background after plugin startup begins.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
|
||||
client.model.list({ location }),
|
||||
client.model.default({ location }),
|
||||
client.agent.list({ location }),
|
||||
client.command.list({ location }),
|
||||
])
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// Parallel reads can straddle initialization; select only from this model list.
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
|
||||
if (defaultModel && defaultAgent) {
|
||||
return {
|
||||
providers: providers(models),
|
||||
models,
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data,
|
||||
}
|
||||
}
|
||||
missing = defaultModel ? "No primary agents are available" : "No models are available"
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error(missing)
|
||||
}
|
||||
|
||||
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
models: models
|
||||
.filter((model) => model.providerID === providerID)
|
||||
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
|
||||
}))
|
||||
}
|
||||
|
||||
export * as ACPCatalog from "./catalog"
|
||||
+107
-138
@@ -1,7 +1,7 @@
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import {
|
||||
isSessionNotFoundError,
|
||||
type CommandInfo,
|
||||
type ModelInfo,
|
||||
type ModelRef,
|
||||
type OpenCodeClient,
|
||||
type SessionInfo,
|
||||
@@ -40,12 +40,8 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import {
|
||||
buildConfigOptions,
|
||||
DEFAULT_VARIANT_VALUE,
|
||||
parseModelSelection,
|
||||
type ConfigOptionProvider,
|
||||
} from "./config-option"
|
||||
import { ACPCatalog, type Catalog } from "./catalog"
|
||||
import { buildConfigOptions, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { promptContentToParts } from "./content"
|
||||
import {
|
||||
@@ -61,22 +57,14 @@ import { ACPError } from "./error"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ModelInfo[]
|
||||
readonly defaultModel: ModelRef
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
readonly commands: CommandInfo[]
|
||||
}
|
||||
|
||||
// Model and mode are unset while the session follows the server defaults.
|
||||
type Attached = {
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly abort: AbortController
|
||||
catalog: Catalog
|
||||
model: ModelRef
|
||||
modeID: string
|
||||
readonly catalog: ACPCatalog.Live
|
||||
model?: ModelRef
|
||||
modeID?: string
|
||||
}
|
||||
|
||||
type PreparedPrompt = {
|
||||
@@ -109,21 +97,55 @@ export function make(input: {
|
||||
readonly connection: ACPConnection.Connection
|
||||
}): Interface {
|
||||
const sessions = new Map<string, Attached>()
|
||||
const catalogs = new Map<string, Promise<Catalog>>()
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
|
||||
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||
|
||||
const catalog = (cwd: string) => {
|
||||
const cached = catalogs.get(cwd)
|
||||
if (cached) return cached
|
||||
const loaded = loadCatalog(input.client, cwd).catch((error) => {
|
||||
catalogs.delete(cwd)
|
||||
throw error
|
||||
const catalogs = ACPCatalog.make({
|
||||
client: input.client,
|
||||
signal: input.connection.signal,
|
||||
changed: (live, previous) =>
|
||||
Promise.all(
|
||||
Array.from(sessions.values())
|
||||
.filter((state) => state.catalog === live)
|
||||
.map(async (state) => {
|
||||
const options = configOptions(state)
|
||||
if (!isDeepStrictEqual(options, configOptions(state, previous))) {
|
||||
await input.connection.sessionUpdate({
|
||||
sessionId: state.id,
|
||||
update: { sessionUpdate: "config_option_update", configOptions: options },
|
||||
})
|
||||
}
|
||||
if (!isDeepStrictEqual(live.current.commands, previous.commands)) await sendCommands(state)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const sendCommands = (state: Attached) =>
|
||||
input.connection.sessionUpdate({
|
||||
sessionId: state.id,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: state.catalog.current.commands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description ?? "",
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
const withReload = <A>(state: Attached, select: () => Promise<A>) =>
|
||||
select().catch(async (error: unknown) => {
|
||||
if (
|
||||
!(
|
||||
error instanceof ACPError.InvalidModelError ||
|
||||
error instanceof ACPError.InvalidModeError ||
|
||||
error instanceof ACPError.InvalidEffortError
|
||||
)
|
||||
)
|
||||
throw error
|
||||
await catalogs.reload(state.catalog)
|
||||
return select()
|
||||
})
|
||||
catalogs.set(cwd, loaded)
|
||||
return loaded
|
||||
}
|
||||
|
||||
const requireSession = async (sessionID: string) => {
|
||||
const current = sessions.get(sessionID)
|
||||
@@ -147,27 +169,19 @@ export function make(input: {
|
||||
}
|
||||
|
||||
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
||||
const currentCatalog = await catalog(cwd)
|
||||
const catalog = await catalogs.get(cwd)
|
||||
sessions.get(session.id)?.abort.abort()
|
||||
const state: Attached = {
|
||||
id: session.id,
|
||||
cwd,
|
||||
abort: new AbortController(),
|
||||
catalog: currentCatalog,
|
||||
model: session.model ?? currentCatalog.defaultModel,
|
||||
modeID: session.agent ?? currentCatalog.defaultModeID,
|
||||
catalog,
|
||||
model: session.model,
|
||||
modeID: session.agent,
|
||||
}
|
||||
sessions.set(session.id, state)
|
||||
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
|
||||
await input.connection.sessionUpdate({
|
||||
sessionId: state.id,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
...state.catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
],
|
||||
},
|
||||
})
|
||||
await sendCommands(state)
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -175,14 +189,16 @@ export function make(input: {
|
||||
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
|
||||
}
|
||||
|
||||
const configOptions = (state: Attached) =>
|
||||
buildConfigOptions({
|
||||
providers: state.catalog.providers,
|
||||
currentModel: { providerID: state.model.providerID, modelID: state.model.id },
|
||||
currentVariant: state.model.variant,
|
||||
modes: state.catalog.modes,
|
||||
currentModeId: state.modeID,
|
||||
const configOptions = (state: Attached, catalog = state.catalog.current) => {
|
||||
const model = currentModel(state, catalog)
|
||||
return buildConfigOptions({
|
||||
providers: catalog.providers,
|
||||
currentModel: { providerID: model.providerID, modelID: model.id },
|
||||
currentVariant: model.variant,
|
||||
modes: catalog.modes,
|
||||
currentModeId: state.modeID ?? catalog.defaultModeID,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: async (params) => {
|
||||
@@ -216,12 +232,10 @@ export function make(input: {
|
||||
return {}
|
||||
},
|
||||
newSession: async (params) => {
|
||||
const currentCatalog = await catalog(params.cwd)
|
||||
const created = await input.client.session.create({
|
||||
location: { directory: params.cwd },
|
||||
agent: currentCatalog.defaultModeID,
|
||||
model: currentCatalog.defaultModel,
|
||||
})
|
||||
// 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.
|
||||
await catalogs.get(params.cwd)
|
||||
const created = await input.client.session.create({ location: { directory: params.cwd } })
|
||||
const state = await attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: state.id, configOptions: configOptions(state) }
|
||||
},
|
||||
@@ -279,37 +293,38 @@ export function make(input: {
|
||||
},
|
||||
setSessionConfigOption: async (params) => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
switch (params.configId) {
|
||||
case "model": {
|
||||
const selected = requireModel(state.catalog, params.value, state.model)
|
||||
state.model = selected
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
break
|
||||
const value = params.value
|
||||
if (typeof value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
await withReload(state, async () => {
|
||||
switch (params.configId) {
|
||||
case "model": {
|
||||
const selected = requireModel(state.catalog.current, value, currentModel(state))
|
||||
state.model = selected
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
return
|
||||
}
|
||||
case "effort": {
|
||||
const current = currentModel(state)
|
||||
const model = state.catalog.current.models.find(
|
||||
(item) => item.providerID === current.providerID && item.id === current.id,
|
||||
)
|
||||
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
|
||||
throw new ACPError.InvalidEffortError({ effort: value })
|
||||
state.model = { ...current, variant: value }
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
|
||||
return
|
||||
}
|
||||
case "mode":
|
||||
return selectMode(input.client, state, value)
|
||||
default:
|
||||
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
case "effort": {
|
||||
const model = state.catalog.models.find(
|
||||
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
|
||||
)
|
||||
if (
|
||||
!model ||
|
||||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
|
||||
)
|
||||
throw new ACPError.InvalidEffortError({ effort: params.value })
|
||||
state.model = { ...state.model, variant: params.value }
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
|
||||
break
|
||||
}
|
||||
case "mode":
|
||||
await selectMode(input.client, state, params.value)
|
||||
break
|
||||
default:
|
||||
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
})
|
||||
return { configOptions: configOptions(state) }
|
||||
},
|
||||
setSessionMode: async (params) => {
|
||||
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
|
||||
const state = await requireSession(params.sessionId)
|
||||
await withReload(state, () => selectMode(input.client, state, params.modeId))
|
||||
return {}
|
||||
},
|
||||
prompt: async (params, signal) => {
|
||||
@@ -321,7 +336,7 @@ export function make(input: {
|
||||
})
|
||||
}
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
||||
const prepared = preparePrompt(state.catalog.current, params.prompt, messageID)
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const extNotification = input.connection.extNotification
|
||||
const childSessionUpdate =
|
||||
@@ -410,58 +425,6 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
// Some providers discover models in the background after plugin startup begins.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
|
||||
client.model.list({ location }),
|
||||
client.model.default({ location }),
|
||||
client.agent.list({ location }),
|
||||
client.command.list({ location }),
|
||||
])
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// Parallel reads can straddle initialization; select only from this model list.
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
|
||||
if (defaultModel && defaultAgent) {
|
||||
return {
|
||||
providers: providers(models),
|
||||
models,
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data,
|
||||
}
|
||||
}
|
||||
missing = defaultModel ? "No primary agents are available" : "No models are available"
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error(missing)
|
||||
}
|
||||
|
||||
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
models: models
|
||||
.filter((model) => model.providerID === providerID)
|
||||
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
|
||||
}))
|
||||
}
|
||||
|
||||
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
@@ -480,8 +443,13 @@ function requireModel(catalog: Catalog, modelID: string, current: ModelRef): Mod
|
||||
return { providerID: model.providerID, id: model.id, variant }
|
||||
}
|
||||
|
||||
function currentModel(state: Attached, catalog = state.catalog.current) {
|
||||
return state.model ?? catalog.defaultModel
|
||||
}
|
||||
|
||||
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
|
||||
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
|
||||
if (!state.catalog.current.modes.some((mode) => mode.id === modeID))
|
||||
throw new ACPError.InvalidModeError({ mode: modeID })
|
||||
state.modeID = modeID
|
||||
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
|
||||
}
|
||||
@@ -567,8 +535,9 @@ async function sendUsageUpdate(
|
||||
used?: number,
|
||||
) {
|
||||
if (!used) return
|
||||
const model = session.catalog.models.find(
|
||||
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
|
||||
const current = currentModel(session)
|
||||
const model = session.catalog.current.models.find(
|
||||
(item) => item.providerID === current.providerID && item.id === current.id,
|
||||
)
|
||||
if (!model?.limit.context) return
|
||||
const info = await client.session.get({ sessionID: session.id })
|
||||
|
||||
@@ -67,6 +67,9 @@ type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||
// attached client must not cancel input that may belong to another session.
|
||||
const GLOBAL_FORM_SESSION_ID = "global"
|
||||
|
||||
const QUESTION_CANCELLED_FEEDBACK =
|
||||
"This non-interactive run cannot ask the user questions, so the question was cancelled. Continue without an answer; make reasonable assumptions and state them."
|
||||
|
||||
const PERMISSION_REJECTED_FEEDBACK =
|
||||
"This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action."
|
||||
|
||||
@@ -182,15 +185,23 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
}
|
||||
|
||||
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
|
||||
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID" | "metadata">) => {
|
||||
// Nobody can answer a question here. Outside V1 compatibility, cancel it with a message so the
|
||||
// question tool fails as ordinary model-visible output and the model continues without an answer.
|
||||
const continuing = request.metadata?.kind === "question" && input.compatibility !== "v1"
|
||||
try {
|
||||
await input.client.session.form.cancel(
|
||||
{ sessionID: request.sessionID, formID: request.id },
|
||||
{
|
||||
sessionID: request.sessionID,
|
||||
formID: request.id,
|
||||
...(continuing ? { message: QUESTION_CANCELLED_FEEDBACK } : {}),
|
||||
},
|
||||
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
|
||||
)
|
||||
} catch (error) {
|
||||
if (!formAlreadySettled(error)) throw error
|
||||
}
|
||||
if (continuing) return
|
||||
formCancelled = true
|
||||
if (input.compatibility !== "v1") process.exitCode = 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionConfigOption, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { currentValue, requireSelectOption, selectValues } from "./select-options"
|
||||
import {
|
||||
buildAgent,
|
||||
ephemeralEvent,
|
||||
planAgent,
|
||||
reviewCommand,
|
||||
rpcError,
|
||||
secondModel,
|
||||
startSession,
|
||||
startWire,
|
||||
testModel,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
|
||||
describe("acp catalog and config options over the wire", () => {
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const first = await Promise.all([acp.newSession("/workspace"), acp.newSession("/workspace")])
|
||||
const other = await acp.newSession("/other")
|
||||
|
||||
expect(currentValue(first[0], "model")).toBe("test/test-model")
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
(["model", "default", "agent", "command"] as const).map((kind) =>
|
||||
acp.server.catalogReads.filter((read) => read.kind === kind).map((read) => read.directory),
|
||||
),
|
||||
).toEqual(Array.from({ length: 4 }, () => ["/workspace", "/other"]))
|
||||
expect(
|
||||
Object.fromEntries([...acp.server.sessions.values()].map((session) => [session.id, session.location.directory])),
|
||||
).toEqual({
|
||||
[first[0].sessionId]: "/workspace",
|
||||
[first[1].sessionId]: "/workspace",
|
||||
[other.sessionId]: "/other",
|
||||
})
|
||||
await acp.until(() => acp.updates.filter((item) => commandNames(item)).length === 3, "commands for each session")
|
||||
expect(acp.updates.map(commandNames)).toEqual([["review"], ["review"], ["review"]])
|
||||
})
|
||||
|
||||
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
|
||||
const configured = { ...buildAgent, id: "copilot-build", name: "copilot-build" }
|
||||
await using acp = await startSession()
|
||||
expect(currentValue(acp.session, "mode")).toBe("build")
|
||||
expect(currentValue(acp.session, "model")).toBe("test/test-model")
|
||||
|
||||
const reads = agentReads(acp)
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/other" }))
|
||||
acp.server.catalog.agents = [configured, buildAgent, planAgent]
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
|
||||
|
||||
const update = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
|
||||
expect(update.update.sessionUpdate === "config_option_update" && modeOption(update.update.configOptions)).toEqual({
|
||||
currentValue: "copilot-build",
|
||||
options: ["copilot-build", "build", "plan"],
|
||||
})
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
|
||||
expect(commandNames(commands)).toEqual(["review", "ship"])
|
||||
expect(agentReads(acp)).toBe(reads + 1)
|
||||
|
||||
const second = await acp.newSession()
|
||||
expect(currentValue(second, "mode")).toBe("copilot-build")
|
||||
})
|
||||
|
||||
test("pushes config options on model.updated and commands on command.updated", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession()
|
||||
expect(selectValues(session.configOptions, "model")).toEqual(["test/test-model"])
|
||||
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
const options = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
|
||||
expect(
|
||||
options.update.sessionUpdate === "config_option_update" && selectValues(options.update.configOptions, "model"),
|
||||
).toEqual(["test/second-model", "test/test-model"])
|
||||
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
|
||||
expect(commands).toEqual({
|
||||
sessionId: session.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "config_option_update")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reloads the catalog before rejecting a model or mode it has not seen", async () => {
|
||||
const configured = { ...planAgent, id: "copilot-build", name: "copilot-build" }
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession()
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.catalog.agents = [buildAgent, planAgent, configured]
|
||||
|
||||
const model = await acp.request("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
|
||||
const reads = agentReads(acp)
|
||||
const missing = await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "mode", value: "missing" }),
|
||||
)
|
||||
|
||||
expect(currentValue(model, "model")).toBe("test/second-model")
|
||||
expect(acp.server.selections).toContainEqual({ sessionID: session.sessionId, agent: "copilot-build" })
|
||||
expect(missing).toMatchObject({ code: -32602, data: { mode: "missing" } })
|
||||
expect(agentReads(acp)).toBeGreaterThan(reads)
|
||||
})
|
||||
|
||||
test.each(["empty", "missing the default"])(
|
||||
"retries when the model list is %s but the default is ready",
|
||||
async (initial) => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = initial === "empty" ? [] : [secondModel]
|
||||
acp.server.catalog.defaultModel = testModel
|
||||
await acp.initialize()
|
||||
|
||||
const created = acp.newSession()
|
||||
await acp.until(() => modelReads(acp) === 1, "first model read")
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
const session = await created
|
||||
|
||||
const choices = selectValues(session.configOptions, "model")
|
||||
expect(choices).toContain("test/second-model")
|
||||
expect(choices).toContain("test/test-model")
|
||||
expect(currentValue(session, "model")).toBe("test/test-model")
|
||||
expect(modelReads(acp)).toBe(2)
|
||||
},
|
||||
)
|
||||
|
||||
test("does not cache a failed catalog load", async () => {
|
||||
const failure = { pending: true }
|
||||
await using acp = await startWire({
|
||||
fetch(request) {
|
||||
if (request.path !== "/api/model" || !failure.pending) return undefined
|
||||
failure.pending = false
|
||||
return Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
|
||||
},
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603 })
|
||||
expect(acp.server.sessions.size).toBe(0)
|
||||
const retried = await acp.newSession()
|
||||
|
||||
expect(acp.server.sessions.has(retried.sessionId)).toBe(true)
|
||||
expect(modelReads(acp)).toBe(1)
|
||||
})
|
||||
|
||||
test("switches model, effort, and mode against the warm catalog", async () => {
|
||||
await using acp = await startSession()
|
||||
const sessionId = acp.sessionId
|
||||
const set = (configId: string, value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId, configId, value })
|
||||
|
||||
const selectedModel = await set("model", "test/second-model")
|
||||
const selectedEffort = await set("effort", "medium")
|
||||
const selectedMode = await set("mode", "plan")
|
||||
await acp.request("session/set_mode", { sessionId, modeId: "build" })
|
||||
|
||||
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
||||
expect(currentValue(selectedModel, "effort")).toBe("default")
|
||||
expect(currentValue(selectedEffort, "effort")).toBe("medium")
|
||||
expect(currentValue(selectedMode, "mode")).toBe("plan")
|
||||
expect(acp.server.selections).toEqual([
|
||||
{ sessionID: sessionId, model: { providerID: "test", id: secondModel.id } },
|
||||
{ sessionID: sessionId, model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
||||
{ sessionID: sessionId, agent: "plan" },
|
||||
{ sessionID: sessionId, agent: "build" },
|
||||
])
|
||||
expect(modelReads(acp)).toBe(1)
|
||||
|
||||
expect(await rpcError(set("effort", "maximum"))).toMatchObject({ code: -32602, data: { effort: "maximum" } })
|
||||
expect(await rpcError(set("mode", "missing"))).toMatchObject({ code: -32602, data: { mode: "missing" } })
|
||||
expect(await rpcError(set("missing", "value"))).toMatchObject({ code: -32602, data: { configId: "missing" } })
|
||||
expect(await rpcError(set("model", "test/missing-model"))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { modelId: "test/missing-model" },
|
||||
})
|
||||
})
|
||||
|
||||
test.todo(
|
||||
"advertises the built-in compact command (https://github.com/anomalyco/opencode/issues/37229)",
|
||||
async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
expect(commandNames(commands)).toContain("compact")
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function commandNames(item: SessionNotification) {
|
||||
if (item.update.sessionUpdate !== "available_commands_update") return undefined
|
||||
return item.update.availableCommands.map((command) => command.name)
|
||||
}
|
||||
|
||||
function agentReads(acp: Wire) {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === "agent").length
|
||||
}
|
||||
|
||||
function modelReads(acp: Wire) {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === "model").length
|
||||
}
|
||||
|
||||
function modeOption(options: SessionConfigOption[]) {
|
||||
const mode = requireSelectOption(options, "mode")
|
||||
return { currentValue: mode.currentValue, options: selectValues(options, "mode") }
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
alternateValue,
|
||||
createAcpFixture,
|
||||
expectOk,
|
||||
flattenSelectOptions,
|
||||
initialize,
|
||||
newSession,
|
||||
requireSelectOption,
|
||||
selectConfigOption,
|
||||
} from "./subprocess"
|
||||
import { alternateValue, flattenSelectOptions, requireSelectOption, selectConfigOption } from "./select-options"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp config option subprocess", () => {
|
||||
test('model option is listed with category "model"', async () => {
|
||||
|
||||
@@ -1,910 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { resolve } from "node:path"
|
||||
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
|
||||
type Fixture = ReturnType<typeof createSseFixture>
|
||||
|
||||
describe("acp event behavior", () => {
|
||||
test("subscribes before admission and isolates sessions and input IDs", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_before",
|
||||
ordinal: 0,
|
||||
delta: "before admission",
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_wrong_input",
|
||||
ordinal: 0,
|
||||
delta: "wrong input",
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_b",
|
||||
assistantMessageID: "msg_b",
|
||||
ordinal: 0,
|
||||
delta: "other session",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_a",
|
||||
ordinal: 0,
|
||||
delta: "accepted",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_a",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_a",
|
||||
inboxID: "input_a",
|
||||
})
|
||||
|
||||
expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
|
||||
"/api/event",
|
||||
"/api/session/ses_a/prompt",
|
||||
])
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionId: "ses_a",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_a",
|
||||
content: { type: "text", text: "accepted" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
|
||||
const firstUpdate = Promise.withResolvers<void>()
|
||||
const releaseUpdate = Promise.withResolvers<void>()
|
||||
const allUpdates = Promise.withResolvers<void>()
|
||||
const releaseSubmit = Promise.withResolvers<void>()
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
async onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id }))
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 0,
|
||||
delta: "think-1",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 0,
|
||||
delta: " continued",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 1,
|
||||
delta: "answer",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 1,
|
||||
delta: "think-2",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
|
||||
await releaseSubmit.promise
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
if (updates.length === 1) {
|
||||
firstUpdate.resolve()
|
||||
await releaseUpdate.promise
|
||||
}
|
||||
if (updates.length === 4) allUpdates.resolve()
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
} satisfies Connection
|
||||
const result = turn({ fixture, connection, sessionID: "ses_order", inboxID: "input_order" })
|
||||
|
||||
try {
|
||||
await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
|
||||
|
||||
releaseUpdate.resolve()
|
||||
await withTimeout(allUpdates.promise, "ordered updates did not finish")
|
||||
expect(await Promise.race([result.then(() => "resolved"), Promise.resolve("pending")])).toBe("pending")
|
||||
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
|
||||
|
||||
releaseSubmit.resolve()
|
||||
const response = await withTimeout(result, "turn did not resolve after admission returned")
|
||||
|
||||
expect(
|
||||
updates.map((item) => {
|
||||
if (
|
||||
item.update.sessionUpdate === "agent_message_chunk" ||
|
||||
item.update.sessionUpdate === "agent_thought_chunk"
|
||||
) {
|
||||
return [
|
||||
item.update.sessionUpdate,
|
||||
item.update.messageId,
|
||||
item.update.content.type === "text" ? item.update.content.text : undefined,
|
||||
]
|
||||
}
|
||||
return [item.update.sessionUpdate, undefined]
|
||||
}),
|
||||
).toEqual([
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
|
||||
["agent_message_chunk", "msg_order", "answer"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
|
||||
])
|
||||
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
|
||||
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
|
||||
|
||||
const replayed: SessionUpdateParams[] = []
|
||||
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
|
||||
{
|
||||
id: "msg_order",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
time: { created: 1 },
|
||||
content: [
|
||||
{ type: "reasoning", text: "think-1 continued" },
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "think-2" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(replayed).toEqual([
|
||||
{
|
||||
sessionId: "ses_order",
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "msg_order:reasoning:0",
|
||||
content: { type: "text", text: "think-1 continued" },
|
||||
},
|
||||
},
|
||||
updates[2],
|
||||
updates[3],
|
||||
])
|
||||
} finally {
|
||||
releaseUpdate.resolve()
|
||||
releaseSubmit.resolve()
|
||||
await result.catch(() => undefined)
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("projects foreground child session updates onto the parent turn", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_child",
|
||||
...childSession("ses_child", "ses_parent", "Explore code"),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
input: { path: "/workspace/src/index.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "source" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_parent",
|
||||
inboxID: "input_parent",
|
||||
})
|
||||
|
||||
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
|
||||
["ses_parent", "tool_call"],
|
||||
["ses_parent", "tool_call_update"],
|
||||
["ses_parent", "tool_call_update"],
|
||||
])
|
||||
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
|
||||
"ses_child:call_read",
|
||||
"ses_child:call_read",
|
||||
"ses_child:call_read",
|
||||
])
|
||||
expect(updates[0]?.update).toMatchObject({
|
||||
title: "Explore code: read",
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_child",
|
||||
parentID: "ses_parent",
|
||||
depth: 1,
|
||||
title: "Explore code",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("continues child extension updates after the parent turn ends", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const childUpdates: ChildSessionUpdate[] = []
|
||||
const completed = Promise.withResolvers<void>()
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_background",
|
||||
...childSession("ses_background", "ses_parent", "Background research"),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_parent",
|
||||
inboxID: "input_parent",
|
||||
childSessionUpdate: async (update) => {
|
||||
childUpdates.push(update)
|
||||
if (update.type === "status" && update.status === "completed") completed.resolve()
|
||||
},
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
|
||||
fixture.send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_future",
|
||||
...childSession("ses_future", "ses_parent", "Later turn child"),
|
||||
}),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
|
||||
fixture.send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
name: "shell",
|
||||
}),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
input: { command: "pwd" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
|
||||
await withTimeout(completed.promise, "background child completion was not delivered")
|
||||
|
||||
expect(updates).toEqual([])
|
||||
expect(
|
||||
childUpdates.map((update) =>
|
||||
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
|
||||
),
|
||||
).toEqual([
|
||||
["status", "created"],
|
||||
["status", "running"],
|
||||
["update", "tool_call"],
|
||||
["update", "tool_call_update"],
|
||||
["update", "tool_call_update"],
|
||||
["status", "completed"],
|
||||
])
|
||||
expect(childUpdates[2]).toMatchObject({
|
||||
rootSessionId: "ses_parent",
|
||||
childSessionId: "ses_background",
|
||||
parentSessionId: "ses_parent",
|
||||
depth: 1,
|
||||
title: "Background research",
|
||||
type: "update",
|
||||
update: { toolCallId: "ses_background:call_shell" },
|
||||
})
|
||||
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("streams tool pending, progress, success, and failure updates", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
name: "shell",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
input: { command: "printf done", workdir: "sub" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
metadata: { phase: 1 },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
name: "read",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
metadata: { bytes: 0 },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.failed", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_tools",
|
||||
inboxID: "input_tools",
|
||||
})
|
||||
|
||||
expect(
|
||||
updates.map((item) => [
|
||||
item.update.sessionUpdate,
|
||||
"status" in item.update ? item.update.status : undefined,
|
||||
"toolCallId" in item.update ? item.update.toolCallId : undefined,
|
||||
]),
|
||||
).toEqual([
|
||||
["tool_call", "pending", "call_ok"],
|
||||
["tool_call_update", "in_progress", "call_ok"],
|
||||
["tool_call_update", "in_progress", "call_ok"],
|
||||
["tool_call_update", "completed", "call_ok"],
|
||||
["tool_call", "pending", "call_fail"],
|
||||
["tool_call_update", "in_progress", "call_fail"],
|
||||
["tool_call_update", "in_progress", "call_fail"],
|
||||
["tool_call_update", "failed", "call_fail"],
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
title: "printf done",
|
||||
kind: "execute",
|
||||
locations: [{ path: resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
})
|
||||
expect(updates[2]?.update).not.toHaveProperty("content")
|
||||
expect(updates[3]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[7]?.update).toMatchObject({
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/missing.ts" }],
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "opening" } },
|
||||
{ type: "content", content: { type: "text", text: "not found" } },
|
||||
],
|
||||
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("replays user, text, reasoning, and tool messages in order", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const messages = replayFixtureMessages()
|
||||
const connection = {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
} satisfies Pick<AgentSideConnection, "sessionUpdate">
|
||||
|
||||
await replayMessages(connection, "ses_replay", "/workspace", messages)
|
||||
|
||||
expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
|
||||
expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
|
||||
"user_message_chunk",
|
||||
"user_message_chunk",
|
||||
"user_message_chunk",
|
||||
"agent_message_chunk",
|
||||
"agent_thought_chunk",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: "file:///workspace/note.md",
|
||||
name: "note.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
})
|
||||
expect(updates[2]?.update).toMatchObject({
|
||||
content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
|
||||
})
|
||||
expect(updates[6]?.update).toMatchObject({
|
||||
toolCallId: "call_done",
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "done" } },
|
||||
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
|
||||
],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[8]?.update).toMatchObject({
|
||||
toolCallId: "call_running",
|
||||
status: "in_progress",
|
||||
title: "pwd",
|
||||
locations: [{ path: "/workspace" }],
|
||||
})
|
||||
expect(updates[10]?.update).toMatchObject({
|
||||
toolCallId: "call_failed",
|
||||
status: "failed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "partial" } },
|
||||
{ type: "content", content: { type: "text", text: "failed hard" } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("continues replay after a session update callback rejects", async () => {
|
||||
const attempts: Array<[string, string]> = []
|
||||
const connection = {
|
||||
sessionUpdate: async (params) => {
|
||||
if (params.update.sessionUpdate !== "tool_call" && params.update.sessionUpdate !== "tool_call_update") return
|
||||
attempts.push([params.update.toolCallId, params.update.sessionUpdate])
|
||||
if (params.update.toolCallId === "call_first" && params.update.sessionUpdate === "tool_call_update") {
|
||||
throw new Error("replay send failed")
|
||||
}
|
||||
},
|
||||
} satisfies Pick<AgentSideConnection, "sessionUpdate">
|
||||
|
||||
await replayMessages(connection, "ses_replay_failure", "/workspace", [
|
||||
replayToolMessage("call_first"),
|
||||
replayToolMessage("call_after"),
|
||||
])
|
||||
|
||||
expect(attempts).toEqual([
|
||||
["call_first", "tool_call"],
|
||||
["call_first", "tool_call_update"],
|
||||
["call_after", "tool_call"],
|
||||
["call_after", "tool_call_update"],
|
||||
])
|
||||
})
|
||||
|
||||
test("returns cancelled after an admitted turn is interrupted", async () => {
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
|
||||
},
|
||||
onInterrupt({ sessionID, send }) {
|
||||
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
|
||||
return true
|
||||
},
|
||||
})
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
sessionID: "ses_cancel",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: async (signal) => {
|
||||
await fixture.client.session.prompt(
|
||||
{ sessionID: "ses_cancel", id: "input_cancel", text: "cancel me" },
|
||||
{ signal },
|
||||
)
|
||||
submitted.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
|
||||
|
||||
const response = await withTimeout(result, "cancelled turn did not terminate")
|
||||
expect(response).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("returns cancelled when admission is aborted before promotion", async () => {
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ signal }) {
|
||||
submitted.resolve()
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) return resolve()
|
||||
signal.addEventListener("abort", () => resolve(), { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
sessionID: "ses_cancel_admission",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel_admission" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: (signal) =>
|
||||
fixture.client.session.prompt(
|
||||
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
|
||||
{ signal },
|
||||
),
|
||||
})
|
||||
|
||||
try {
|
||||
await withTimeout(submitted.promise, "cancel test prompt was not submitted")
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
|
||||
const response = await withTimeout(result, "pre-admission cancellation did not terminate")
|
||||
expect(response).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
|
||||
} finally {
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
await result.catch(() => undefined)
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels unsupported session forms so execution can continue", async () => {
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
|
||||
send(
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_question",
|
||||
sessionID: "ses_form",
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "q0", title: "Choice", type: "string" }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
onFormCancel({ sessionID, formID, send }) {
|
||||
send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection([]),
|
||||
sessionID: "ses_form",
|
||||
inboxID: "input_form",
|
||||
})
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(
|
||||
fixture.requests.some(
|
||||
(request) => request.method === "DELETE" && request.path === "/api/session/ses_form/form/frm_question",
|
||||
),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function recordingConnection(updates: SessionUpdateParams[]) {
|
||||
return {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
} satisfies Connection
|
||||
}
|
||||
|
||||
function turn(input: {
|
||||
readonly fixture: Fixture
|
||||
readonly connection: Connection
|
||||
readonly sessionID: string
|
||||
readonly inboxID: string
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
}) {
|
||||
return streamTurn({
|
||||
client: input.fixture.client,
|
||||
connection: input.connection,
|
||||
sessionID: input.sessionID,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: input.inboxID },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
childSessionUpdate: input.childSessionUpdate,
|
||||
submit: (signal) =>
|
||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
function childSession(id: string, parentID: string, title: string) {
|
||||
return {
|
||||
slug: id,
|
||||
projectID: "project",
|
||||
location: { directory: "/workspace" },
|
||||
parentID,
|
||||
title,
|
||||
version: "test",
|
||||
}
|
||||
}
|
||||
|
||||
function tokens() {
|
||||
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
}
|
||||
|
||||
function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
return [
|
||||
{
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
time: { created: 1 },
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "text/markdown",
|
||||
name: "note.md",
|
||||
source: { type: "uri", uri: "file:///workspace/note.md" },
|
||||
},
|
||||
{
|
||||
data: "aGVsbG8=",
|
||||
mime: "text/plain",
|
||||
name: "inline.txt",
|
||||
source: { type: "inline" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_done",
|
||||
name: "shell",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_running",
|
||||
name: "shell",
|
||||
time: { created: 2, ran: 2 },
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "pwd" },
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_failed",
|
||||
name: "read",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "error",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
error: { type: "tool.error", message: "failed hard" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_streaming",
|
||||
name: "shell",
|
||||
time: { created: 2 },
|
||||
state: { status: "streaming", input: '{"command":' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function replayToolMessage(id: string) {
|
||||
return {
|
||||
id: `msg_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
time: { created: 1, completed: 2 },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id,
|
||||
name: "shell",
|
||||
time: { created: 1, completed: 2 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies SessionMessageInfo
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { streamTurn } from "../../src/acp/event"
|
||||
|
||||
test("acp prompt resolves after ordered turn updates", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
let events: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
events = controller
|
||||
send(controller, { id: "evt_connected", type: "server.connected", data: {} })
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/prompt") {
|
||||
const body: unknown = await request.json()
|
||||
if (!body || typeof body !== "object") {
|
||||
return new Response(null, { status: 400 })
|
||||
}
|
||||
const id = "id" in body ? body.id : undefined
|
||||
if (typeof id !== "string") return new Response(null, { status: 400 })
|
||||
queueMicrotask(() => {
|
||||
if (!events) return
|
||||
send(events, {
|
||||
id: "evt_promoted",
|
||||
created: 1,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_test", inboxID: id },
|
||||
})
|
||||
send(events, {
|
||||
id: "evt_text",
|
||||
created: 2,
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hello" },
|
||||
})
|
||||
send(events, {
|
||||
id: "evt_step",
|
||||
created: 3,
|
||||
type: "session.step.ended",
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_assistant",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
})
|
||||
send(events, {
|
||||
id: "evt_done",
|
||||
created: 4,
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_test" },
|
||||
})
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/message/msg_assistant") {
|
||||
return Response.json({
|
||||
data: {
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
finish: "stop",
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, completed: 4 },
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: server.url.toString() })
|
||||
|
||||
try {
|
||||
const id = "msg_prompt"
|
||||
const response = await streamTurn({
|
||||
client,
|
||||
connection: {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
sessionID: "ses_test",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
||||
})
|
||||
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionId: "ses_test",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_assistant",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
|
||||
} finally {
|
||||
events?.close()
|
||||
await server.stop(true)
|
||||
}
|
||||
|
||||
function send(controller: ReadableStreamDefaultController<Uint8Array>, event: unknown) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
}
|
||||
})
|
||||
|
||||
test("acp action resolves without prompt lifecycle events", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await streamTurn({
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
connection: {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
sessionID: "ses_test",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_action" },
|
||||
writeTextFile: false,
|
||||
action: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: async () => {},
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
ResumeSessionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption } from "./subprocess"
|
||||
import { selectConfigOption } from "./select-options"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
@@ -98,4 +99,30 @@ describe("acp lifecycle subprocess", () => {
|
||||
|
||||
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
|
||||
}, 60_000)
|
||||
|
||||
// The private server is found with `pgrep`, which Windows lacks.
|
||||
const todoOutsideWindows = process.platform === "win32" ? test.skip : test.todo
|
||||
todoOutsideWindows(
|
||||
"exits when the private server process dies (https://github.com/anomalyco/opencode/issues/51716)",
|
||||
async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
await newSession(acp, fixture.home)
|
||||
const servers = Bun.spawnSync(["pgrep", "-P", String(acp.pid)])
|
||||
.stdout.toString()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
expect(servers).toHaveLength(1)
|
||||
|
||||
process.kill(servers[0], "SIGKILL")
|
||||
|
||||
const timeout = Promise.withResolvers<"running">()
|
||||
const timer = setTimeout(() => timeout.resolve("running"), 10_000)
|
||||
const exited = await Promise.race([acp.exited, timeout.promise]).finally(() => clearTimeout(timer))
|
||||
expect(exited).not.toBe("running")
|
||||
},
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { ACPConnection } from "../../src/acp/connection"
|
||||
import { streamTurn } from "../../src/acp/event"
|
||||
import { syncEditedFiles } from "../../src/acp/permission"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = SessionNotification
|
||||
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
|
||||
type Fixture = ReturnType<typeof createSseFixture>
|
||||
|
||||
describe("acp permission behavior", () => {
|
||||
test("does not sync edits when writeTextFile was not advertised", async () => {
|
||||
const writes: WriteTextFileRequest[] = []
|
||||
|
||||
await syncEditedFiles({
|
||||
connection: {
|
||||
writeTextFile: async (input) => {
|
||||
writes.push(input)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
writeTextFile: false,
|
||||
sessionID: "ses_no_write",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
toolInput: { filePath: "/workspace/file.ts" },
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test("forwards allow-once and allow-always selections to the generated client", async () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_allow", inboxID: id }))
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_once", {
|
||||
action: "shell",
|
||||
metadata: { command: "printf hello" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_always", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always",
|
||||
},
|
||||
}
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_allow", "input_allow")
|
||||
|
||||
expect(permissionRequests[0]).toMatchObject({
|
||||
sessionId: "ses_allow",
|
||||
toolCall: {
|
||||
toolCallId: "call_once",
|
||||
status: "pending",
|
||||
title: "printf hello",
|
||||
kind: "execute",
|
||||
locations: [{ path: "/workspace" }],
|
||||
rawInput: { command: "printf hello", cwd: "/workspace" },
|
||||
},
|
||||
options: [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(permissionRequests[1]).toMatchObject({
|
||||
sessionId: "ses_allow",
|
||||
toolCall: {
|
||||
toolCallId: "call_always",
|
||||
status: "pending",
|
||||
title: "/workspace/file.ts",
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/file.ts" }],
|
||||
rawInput: { path: "/workspace/file.ts" },
|
||||
},
|
||||
})
|
||||
expect(permissionReplies(fixture)).toEqual([
|
||||
["perm_once", "once"],
|
||||
["perm_always", "always"],
|
||||
])
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves external directory permission context", async () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_external", inboxID: id }))
|
||||
send(
|
||||
permissionAsked("ses_external", "perm_external", {
|
||||
action: "external_directory",
|
||||
metadata: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_external" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_external", "input_external")
|
||||
|
||||
expect(permissionRequests[0]?.toolCall).toMatchObject({
|
||||
title: "Create external directory",
|
||||
locations: [{ path: "/tmp/outside" }],
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_child",
|
||||
slug: "ses_child",
|
||||
projectID: "project",
|
||||
location: { directory: "/workspace" },
|
||||
parentID: "ses_parent",
|
||||
title: "Review code",
|
||||
version: "test",
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||
send(
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/child.ts" },
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_parent", "input_parent")
|
||||
|
||||
expect(permissionRequests).toHaveLength(1)
|
||||
expect(permissionRequests[0]).toMatchObject({
|
||||
sessionId: "ses_parent",
|
||||
toolCall: {
|
||||
toolCallId: "ses_child:call_child",
|
||||
title: "Review code: /workspace/child.ts",
|
||||
},
|
||||
})
|
||||
expect(fixture.requests).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/session/ses_child/permission/perm_child/reply",
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("previews edits during approval and syncs the completed file", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
||||
const file = path.join(cwd, "file.ts")
|
||||
await fs.writeFile(file, "before")
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const writes: WriteTextFileRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
input: { path: "file.ts", oldString: "before", newString: "after" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
permissionAsked("ses_edit", "perm_edit", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
async onPermissionReply({ send }) {
|
||||
await fs.writeFile(file, "after")
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
content: [{ type: "text", text: "edited" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_edit" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
writeTextFile: async (request) => {
|
||||
writes.push(request)
|
||||
return {}
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_edit", "input_edit", cwd)
|
||||
|
||||
expect(permissionRequests[0]?.toolCall).toMatchObject({
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: "file.ts" }],
|
||||
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
|
||||
})
|
||||
expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
await fs.rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("previews and syncs each file in a patch", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
|
||||
fs.writeFile(path.join(cwd, "second.ts"), "alpha\n"),
|
||||
])
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: second.ts",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const writes: WriteTextFileRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
name: "patch",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
input: { patchText },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
permissionAsked("ses_patch", "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
async onPermissionReply({ send }) {
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(cwd, "first.ts"), "two\n"),
|
||||
fs.writeFile(path.join(cwd, "second.ts"), "beta\n"),
|
||||
])
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
||||
content: [{ type: "text", text: "patched" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_patch" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
writeTextFile: async (request) => {
|
||||
writes.push(request)
|
||||
return {}
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_patch", "input_patch", cwd)
|
||||
|
||||
expect(permissionRequests[0]?.toolCall).toMatchObject({
|
||||
title: "2 files",
|
||||
kind: "edit",
|
||||
locations: [{ path: "first.ts" }, { path: "second.ts" }],
|
||||
content: [
|
||||
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
|
||||
],
|
||||
})
|
||||
expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
|
||||
{ sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
|
||||
{ sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
|
||||
])
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
await fs.rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_reject", inboxID: id }))
|
||||
send(permissionAsked("ses_reject", "perm_selected_reject"))
|
||||
send(permissionAsked("ses_reject", "perm_cancelled"))
|
||||
send(permissionAsked("ses_reject", "perm_failed"))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_reject" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request): Promise<RequestPermissionResponse> => {
|
||||
if (request.toolCall.toolCallId === "perm_selected_reject") {
|
||||
return { outcome: { outcome: "selected", optionId: "reject" } }
|
||||
}
|
||||
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
|
||||
throw new Error("client permission UI failed")
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
const response = await startTurn(fixture, connection, "ses_reject", "input_reject")
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(permissionReplies(fixture)).toEqual([
|
||||
["perm_selected_reject", "reject"],
|
||||
["perm_cancelled", "reject"],
|
||||
["perm_failed", "reject"],
|
||||
])
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("serializes permission requests and replies within one session", async () => {
|
||||
const firstRequested = Promise.withResolvers<void>()
|
||||
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_serial", inboxID: id }))
|
||||
send(permissionAsked("ses_serial", "perm_1"))
|
||||
send(permissionAsked("ses_serial", "perm_2"))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
if (request.toolCall.toolCallId === "perm_1") {
|
||||
firstRequested.resolve()
|
||||
return releaseFirst.promise
|
||||
}
|
||||
return { outcome: { outcome: "selected", optionId: "always" } } as const
|
||||
},
|
||||
} satisfies Connection
|
||||
const result = startTurn(fixture, connection, "ses_serial", "input_serial")
|
||||
|
||||
try {
|
||||
await withTimeout(firstRequested.promise, "first permission was not requested")
|
||||
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
|
||||
expect(permissionReplies(fixture)).toEqual([])
|
||||
|
||||
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
await withTimeout(result, "serialized permission turn did not finish")
|
||||
|
||||
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
|
||||
expect(permissionReplies(fixture)).toEqual([
|
||||
["perm_1", "once"],
|
||||
["perm_2", "always"],
|
||||
])
|
||||
} finally {
|
||||
releaseFirst.resolve({ outcome: { outcome: "cancelled" } })
|
||||
await result.catch(() => undefined)
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not let one session's blocked permission stall another session", async () => {
|
||||
const blockedRequested = Promise.withResolvers<void>()
|
||||
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
|
||||
const promptIDs = new Map<string, string>()
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ sessionID, id, send }) {
|
||||
promptIDs.set(sessionID, id)
|
||||
if (promptIDs.size !== 2) return
|
||||
const blockedID = promptIDs.get("ses_blocked")
|
||||
const freeID = promptIDs.get("ses_free")
|
||||
if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_blocked", inboxID: blockedID }))
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_free", inboxID: freeID }))
|
||||
send(permissionAsked("ses_blocked", "perm_blocked"))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_free",
|
||||
assistantMessageID: "msg_free",
|
||||
ordinal: 0,
|
||||
delta: "session B continued",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_free",
|
||||
assistantMessageID: "msg_free",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_free" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_blocked" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => {
|
||||
blockedRequested.resolve()
|
||||
return releaseBlocked.promise
|
||||
},
|
||||
} satisfies Connection
|
||||
const blocked = startTurn(fixture, connection, "ses_blocked", "input_blocked")
|
||||
const free = startTurn(fixture, connection, "ses_free", "input_free")
|
||||
|
||||
try {
|
||||
await withTimeout(blockedRequested.promise, "blocked permission was not requested")
|
||||
const response = await withTimeout(free, "free session was stalled by another session's permission")
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(updates).toContainEqual({
|
||||
sessionId: "ses_free",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_free",
|
||||
content: { type: "text", text: "session B continued" },
|
||||
},
|
||||
})
|
||||
expect(permissionReplies(fixture)).toEqual([])
|
||||
|
||||
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
await withTimeout(blocked, "blocked session did not resume after permission selection")
|
||||
expect(permissionReplies(fixture)).toEqual([["perm_blocked", "once"]])
|
||||
} finally {
|
||||
releaseBlocked.resolve({ outcome: { outcome: "cancelled" } })
|
||||
await Promise.all([blocked.catch(() => undefined), free.catch(() => undefined)])
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
|
||||
const requested = Promise.withResolvers<AbortSignal | undefined>()
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
|
||||
send(permissionAsked("ses_cancel", "perm_cancel"))
|
||||
},
|
||||
onPermissionReply({ send }) {
|
||||
send(durableEvent("session.execution.interrupted", { sessionID: "ses_cancel", reason: "user" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
// Behaves like a client answering the agent's `$/cancel_request` with a cancelled outcome.
|
||||
requestPermission: (_request, options) =>
|
||||
new Promise<RequestPermissionResponse>((resolve) => {
|
||||
options?.cancellationSignal?.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), {
|
||||
once: true,
|
||||
})
|
||||
requested.resolve(options?.cancellationSignal)
|
||||
}),
|
||||
} satisfies Connection
|
||||
const control = { cancelled: false, admission: new AbortController() }
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection,
|
||||
sessionID: "ses_cancel",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: (signal) =>
|
||||
fixture.client.session.prompt({ sessionID: "ses_cancel", id: "input_cancel", text: "hello" }, { signal }),
|
||||
})
|
||||
|
||||
try {
|
||||
const signal = await withTimeout(requested.promise, "permission was not requested")
|
||||
expect(signal?.aborted).toBe(false)
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
|
||||
expect(await withTimeout(result, "cancelled turn did not finish")).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(permissionReplies(fixture)).toEqual([["perm_cancel", "reject"]])
|
||||
} finally {
|
||||
await result.catch(() => undefined)
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
|
||||
return streamTurn({
|
||||
client: fixture.client,
|
||||
connection,
|
||||
sessionID,
|
||||
cwd,
|
||||
start: { type: "input", id: inboxID },
|
||||
writeTextFile: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
function permissionAsked(
|
||||
sessionID: string,
|
||||
id: string,
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
} = {},
|
||||
) {
|
||||
return ephemeralEvent("permission.asked", {
|
||||
id,
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
resources: ["*"],
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function permissionReplies(fixture: Fixture) {
|
||||
return fixture.requests.flatMap((request): Array<[string, string]> => {
|
||||
const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
|
||||
if (!match?.[1] || !request.body || typeof request.body !== "object") return []
|
||||
const reply = "decision" in request.body ? request.body.decision : undefined
|
||||
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
interrupted,
|
||||
permissionAsked,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolCalled,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
turn,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const allowOnce = () => ({ outcome: { outcome: "selected", optionId: "once" } }) as const
|
||||
|
||||
describe("acp permissions over the wire", () => {
|
||||
test("forwards allow-once and allow-always selections to the server", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_once", {
|
||||
action: "shell",
|
||||
metadata: { command: "printf hello" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_always", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
),
|
||||
permission: (request) => ({
|
||||
outcome: { outcome: "selected", optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always" },
|
||||
}),
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "call_once",
|
||||
status: "pending",
|
||||
title: "printf hello",
|
||||
kind: "execute",
|
||||
locations: [{ path: "/workspace" }],
|
||||
rawInput: { command: "printf hello", cwd: "/workspace" },
|
||||
},
|
||||
options: [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(acp.permissions[1]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "call_always",
|
||||
status: "pending",
|
||||
title: "/workspace/file.ts",
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/file.ts" }],
|
||||
rawInput: { path: "/workspace/file.ts" },
|
||||
},
|
||||
})
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_once", "once"],
|
||||
["perm_always", "always"],
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves external directory permission context", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_external", {
|
||||
action: "external_directory",
|
||||
metadata: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "Create external directory",
|
||||
locations: [{ path: "/tmp/outside" }],
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/child.ts" },
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions).toHaveLength(1)
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts" },
|
||||
})
|
||||
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
|
||||
})
|
||||
|
||||
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_selected_reject"),
|
||||
permissionAsked(sessionID, "perm_cancelled"),
|
||||
permissionAsked(sessionID, "perm_failed"),
|
||||
),
|
||||
permission(request) {
|
||||
if (request.toolCall.toolCallId === "perm_selected_reject") {
|
||||
return { outcome: { outcome: "selected", optionId: "reject" } }
|
||||
}
|
||||
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
|
||||
throw new Error("client permission UI failed")
|
||||
},
|
||||
})
|
||||
|
||||
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_selected_reject", "reject"],
|
||||
["perm_cancelled", "reject"],
|
||||
["perm_failed", "reject"],
|
||||
])
|
||||
})
|
||||
|
||||
test("serializes permission requests and replies within one session", async () => {
|
||||
const releaseFirst = Promise.withResolvers<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("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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp edit previews and client file sync over the wire", () => {
|
||||
test("previews edits during approval and syncs the completed file", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "before")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "before", newString: "after" }),
|
||||
permissionAsked(sessionID, "perm_edit", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: async ({ sessionID }) => {
|
||||
await fs.writeFile(file, "after")
|
||||
return [
|
||||
toolSucceeded(sessionID, "call_edit", { files: [{ file: "file.ts" }], replacements: 1 }, "edited"),
|
||||
succeeded(sessionID),
|
||||
]
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: "file.ts" }],
|
||||
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
|
||||
})
|
||||
expect(acp.writes).toEqual([{ sessionId: session.sessionId, path: file, content: "after" }])
|
||||
})
|
||||
|
||||
test("previews and syncs each file in a patch", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
|
||||
fs.writeFile(path.join(dir.path, "second.ts"), "alpha\n"),
|
||||
])
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: second.ts",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
permissionAsked(sessionID, "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: async ({ sessionID }) => {
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(dir.path, "first.ts"), "two\n"),
|
||||
fs.writeFile(path.join(dir.path, "second.ts"), "beta\n"),
|
||||
])
|
||||
return [
|
||||
toolSucceeded(sessionID, "call_patch", { files: [{ file: "first.ts" }, { file: "second.ts" }] }, "patched"),
|
||||
succeeded(sessionID),
|
||||
]
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "2 files",
|
||||
kind: "edit",
|
||||
locations: [{ path: "first.ts" }, { path: "second.ts" }],
|
||||
content: [
|
||||
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
|
||||
],
|
||||
})
|
||||
expect(acp.writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
|
||||
{ sessionId: session.sessionId, path: path.join(dir.path, "first.ts"), content: "two\n" },
|
||||
{ sessionId: session.sessionId, path: path.join(dir.path, "second.ts"), content: "beta\n" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not sync edits when the client did not advertise writeTextFile", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "file.ts"), "after")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { filePath: path.join(dir.path, "file.ts") }),
|
||||
toolSucceeded(sessionID, "call_edit", {}, "edited"),
|
||||
),
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.writes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function decisions(acp: Wire) {
|
||||
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StopReason } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import {
|
||||
assistantMessage,
|
||||
delivered,
|
||||
durableEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
makeSession,
|
||||
rpcError,
|
||||
startSession,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
turn,
|
||||
type Wire,
|
||||
type WireOptions,
|
||||
} from "./wire-fixture"
|
||||
|
||||
// A "hold" prompt is admitted and starts streaming, then only finishes when interrupted.
|
||||
const held = {
|
||||
onPrompt: ({ sessionID, id, text }) =>
|
||||
text === "hold" ? [delivered(sessionID, id), textDelta(sessionID, "msg_held", "working")] : turn(sessionID, id),
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
} satisfies WireOptions
|
||||
|
||||
describe("acp prompt turns over the wire", () => {
|
||||
test("streams an admitted turn and resolves with usage after its terminal event", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_assistant", "hello"), stepEnded(sessionID, "msg_assistant")),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hi")
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
_meta: {},
|
||||
})
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "agent_message_chunk")).toEqual([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_assistant",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{
|
||||
kind: "prompt",
|
||||
sessionID: acp.sessionId,
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "hi",
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes slash commands and compact through their session endpoints", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
const command = await acp.prompt(acp.sessionId, "/review now")
|
||||
const compact = await acp.prompt(acp.sessionId, "/compact")
|
||||
|
||||
expect([command.stopReason, compact.stopReason]).toEqual(["end_turn", "end_turn"])
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{ kind: "command", sessionID: acp.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
|
||||
{ kind: "compact", sessionID: acp.sessionId, id: expect.stringMatching(/^msg_/) },
|
||||
])
|
||||
})
|
||||
|
||||
test("submits assistant-only context as synthetic input before the visible prompt", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "visible" },
|
||||
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
|
||||
{ type: "resource_link", uri: "file:///workspace/README.md", name: "README.md", mimeType: "text/markdown" },
|
||||
])
|
||||
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{
|
||||
kind: "synthetic",
|
||||
sessionID: acp.sessionId,
|
||||
text: "hidden context",
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: "visible",
|
||||
files: [{ uri: "file:///workspace/README.md", name: "README.md" }],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
|
||||
const assistantTokens = { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } }
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, stepEnded(sessionID, "msg_assistant", { tokens: assistantTokens })),
|
||||
})
|
||||
await acp.request("session/set_config_option", {
|
||||
sessionId: acp.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
acp.server.sessions.set(acp.sessionId, makeSession(acp.sessionId, { cost: 3.5 }))
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant", { tokens: assistantTokens })])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
thoughtTokens: 7,
|
||||
cachedReadTokens: 11,
|
||||
cachedWriteTokens: 13,
|
||||
totalTokens: 171,
|
||||
},
|
||||
_meta: {},
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "usage_update", used: 171, size: 200_000, cost: { amount: 3.5, currency: "USD" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("completes the prompt when reads after admission fail", async () => {
|
||||
const reads = { failing: false }
|
||||
await using acp = await startSession({
|
||||
fetch: (request) => (reads.failing && request.method === "GET" ? new Response(null, { status: 500 }) : undefined),
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
reads.failing = true
|
||||
return turn(sessionID, id, stepEnded(sessionID, "msg_assistant"))
|
||||
},
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test.each<{ name: string; stopReason: StopReason; events: (sessionID: string) => OpenCodeEvent[] }>([
|
||||
{ name: "a normal stop", stopReason: "end_turn", events: (id) => [stepEnded(id, "msg"), succeeded(id)] },
|
||||
{
|
||||
name: "a length-limited step",
|
||||
stopReason: "max_tokens",
|
||||
events: (id) => [stepEnded(id, "msg", { finish: "length" }), succeeded(id)],
|
||||
},
|
||||
{
|
||||
name: "a content-filtered step",
|
||||
stopReason: "refusal",
|
||||
events: (id) => [stepEnded(id, "msg", { finish: "content-filter" }), succeeded(id)],
|
||||
},
|
||||
{
|
||||
name: "a content-filter failure",
|
||||
stopReason: "refusal",
|
||||
events: (id) => [failed(id, { type: "provider.content-filter", message: "blocked" })],
|
||||
},
|
||||
{
|
||||
name: "a server-side interruption",
|
||||
stopReason: "cancelled",
|
||||
events: (id) => [durableEvent("session.execution.interrupted", { sessionID: id, reason: "shutdown" })],
|
||||
},
|
||||
])("maps $name to $stopReason", async (input) => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...input.events(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe(input.stopReason)
|
||||
})
|
||||
|
||||
test("maps provider auth failures to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
failed(sessionID, { type: "provider.auth", message: "missing key" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("maps an assistant message auth error to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_auth", "partial"), stepEnded(sessionID, "msg_auth")),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
assistantMessage("msg_auth", { error: { type: "provider.auth", message: "expired" } }),
|
||||
])
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("surfaces other execution failures as internal errors with the failure message", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
failed(sessionID, { type: "provider.rate-limit", message: "slow down" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({
|
||||
code: -32603,
|
||||
message: expect.stringContaining("slow down"),
|
||||
})
|
||||
})
|
||||
|
||||
test("reports provider retries while pending and clears them when the next step starts", async () => {
|
||||
const at = Date.UTC(2026, 0, 1)
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
retryScheduled(sessionID, 2, at),
|
||||
durableEvent("session.step.started", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_retry",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
started: at,
|
||||
}),
|
||||
stepEnded(sessionID, "msg_retry"),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "session_info_update")).toEqual([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": retryMeta(2, at) } },
|
||||
},
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": null } },
|
||||
},
|
||||
])
|
||||
expect(response._meta).toEqual({})
|
||||
})
|
||||
|
||||
test("reports the pending retry on a turn cancelled during backoff", async () => {
|
||||
const at = Date.UTC(2026, 0, 1)
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), retryScheduled(sessionID, 1, at)],
|
||||
onInterrupt: held.onInterrupt,
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: { "opencode/retry": retryMeta(1, at) } })
|
||||
})
|
||||
|
||||
test("session/cancel before admission aborts the submission and returns cancelled", async () => {
|
||||
const aborted = Promise.withResolvers<void>()
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ signal }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted.resolve()
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.server.submissions.length === 1, "prompt submission")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
await aborted.promise
|
||||
expect(acp.server.interrupts).toContain(acp.sessionId)
|
||||
})
|
||||
|
||||
test("session/cancel mid-turn interrupts the session once, returns cancelled, and keeps it usable", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await admitted(acp, acp.sessionId)
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
|
||||
await using acp = await startSession(held)
|
||||
const controller = new AbortController()
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold", controller.signal)
|
||||
await admitted(acp, acp.sessionId)
|
||||
controller.abort()
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("session/close settles the active turn before responding and detaches only that session", async () => {
|
||||
await using acp = await startSession(held)
|
||||
const other = await acp.newSession()
|
||||
|
||||
const order: string[] = []
|
||||
const prompt = acp.prompt(acp.sessionId, "hold").then((response) => {
|
||||
order.push("prompt")
|
||||
return response
|
||||
})
|
||||
await admitted(acp, acp.sessionId)
|
||||
const close = await acp.request("session/close", { sessionId: acp.sessionId }).then((response) => {
|
||||
order.push("close")
|
||||
return response
|
||||
})
|
||||
|
||||
expect(close).toEqual({})
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(order).toEqual(["prompt", "close"])
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "again"))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: acp.sessionId },
|
||||
})
|
||||
expect((await acp.prompt(other.sessionId, "still here")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("rejects a second prompt while the session already has an active turn", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const first = acp.prompt(acp.sessionId, "hold")
|
||||
await acp.until(() => acp.server.submissions.length === 1, "first prompt")
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "second"))).toMatchObject({ code: -32603 })
|
||||
expect(acp.server.submissions).toHaveLength(1)
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
expect((await first).stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
test.todo(
|
||||
"reports usage summed across every step of the turn (https://github.com/anomalyco/opencode/issues/41660)",
|
||||
async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
|
||||
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
assistantMessage("msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// The server answered admission before streaming the chunk, and this request round-trips through the server after it,
|
||||
// so the agent has observed admission before the test cancels.
|
||||
async function admitted(acp: Wire, sessionId: string) {
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
|
||||
await acp.request("session/set_mode", { sessionId, modeId: "build" })
|
||||
}
|
||||
|
||||
function retryScheduled(sessionID: string, attempt: number, at: number) {
|
||||
return durableEvent("session.retry.scheduled", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_retry",
|
||||
attempt,
|
||||
at,
|
||||
error: { type: "provider.rate-limit", message: "rate limited" },
|
||||
})
|
||||
}
|
||||
|
||||
function retryMeta(attempt: number, at: number) {
|
||||
return {
|
||||
attempt,
|
||||
nextRetryAt: new Date(at).toISOString(),
|
||||
error: { type: "provider.rate-limit", message: "rate limited" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp session replay over the wire", () => {
|
||||
test("replays user, text, reasoning, and tool messages in order on session/load", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_replay", makeSession("ses_replay"))
|
||||
acp.server.messages.set("ses_replay", replayFixtureMessages())
|
||||
await acp.initialize()
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_replay", mcpServers: [] })
|
||||
|
||||
const updates = acp.updates.filter((item) => item.update.sessionUpdate !== "available_commands_update")
|
||||
expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
|
||||
expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
|
||||
"user_message_chunk",
|
||||
"user_message_chunk",
|
||||
"user_message_chunk",
|
||||
"agent_message_chunk",
|
||||
"agent_thought_chunk",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
content: { type: "resource_link", uri: "file:///workspace/note.md", name: "note.md", mimeType: "text/markdown" },
|
||||
})
|
||||
expect(updates[2]?.update).toMatchObject({
|
||||
content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
|
||||
})
|
||||
expect(updates[4]?.update).toMatchObject({ messageId: "msg_assistant:reasoning:0" })
|
||||
expect(updates[6]?.update).toMatchObject({
|
||||
toolCallId: "call_done",
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "done" } },
|
||||
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
|
||||
],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[8]?.update).toMatchObject({
|
||||
toolCallId: "call_running",
|
||||
status: "in_progress",
|
||||
title: "pwd",
|
||||
locations: [{ path: "/workspace" }],
|
||||
})
|
||||
expect(updates[10]?.update).toMatchObject({
|
||||
toolCallId: "call_failed",
|
||||
status: "failed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "partial" } },
|
||||
{ type: "content", content: { type: "text", text: "failed hard" } },
|
||||
],
|
||||
})
|
||||
expect(updates[11]?.update).toMatchObject({ toolCallId: "call_streaming", status: "pending", rawInput: {} })
|
||||
})
|
||||
|
||||
test("continues replay after one message fails to translate", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch(request) {
|
||||
if (request.path !== "/api/session/ses_replay_failure/message") return undefined
|
||||
return Response.json({
|
||||
data: [
|
||||
replayToolMessage("call_first", { status: "error", input: {}, metadata: {} }),
|
||||
replayToolMessage("call_after", {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
}),
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
},
|
||||
})
|
||||
acp.server.sessions.set("ses_replay_failure", makeSession("ses_replay_failure"))
|
||||
await acp.initialize()
|
||||
|
||||
const loaded = await acp.request("session/load", {
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_replay_failure",
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(loaded.configOptions).toBeDefined()
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
|
||||
? [[item.update.toolCallId, item.update.sessionUpdate]]
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
["call_first", "tool_call"],
|
||||
["call_after", "tool_call"],
|
||||
["call_after", "tool_call_update"],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function replayToolMessage(id: string, state: Record<string, unknown>) {
|
||||
return {
|
||||
...assistantMessage(`msg_${id}`),
|
||||
content: [{ type: "tool", id, name: "shell", time: { created: 1, completed: 2 }, state }],
|
||||
}
|
||||
}
|
||||
|
||||
function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
return [
|
||||
{
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
time: { created: 1 },
|
||||
files: [
|
||||
{ data: "", mime: "text/markdown", name: "note.md", source: { type: "uri", uri: "file:///workspace/note.md" } },
|
||||
{ data: "aGVsbG8=", mime: "text/plain", name: "inline.txt", source: { type: "inline" } },
|
||||
],
|
||||
},
|
||||
assistantMessage("msg_assistant", {
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_done",
|
||||
name: "shell",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_running",
|
||||
name: "shell",
|
||||
time: { created: 2, ran: 2 },
|
||||
state: { status: "running", input: { command: "pwd" }, metadata: {} },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_failed",
|
||||
name: "read",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "error",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
error: { type: "tool.error", message: "failed hard" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_streaming",
|
||||
name: "shell",
|
||||
time: { created: 2 },
|
||||
state: { status: "streaming", input: '{"command":' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
|
||||
export function currentValue(
|
||||
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
|
||||
id: string,
|
||||
) {
|
||||
return result?.configOptions?.find((option) => option.id === id)?.currentValue
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
|
||||
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
|
||||
|
||||
describe("acp service directory behavior", () => {
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
let created = 0
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method !== "POST" || request.path !== "/api/session") return undefined
|
||||
created++
|
||||
return Response.json({
|
||||
data: makeSession(`ses_${created}`, { cwd: created <= 2 ? "/workspace" : "/other" }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const first = await Promise.all([
|
||||
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
|
||||
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
|
||||
])
|
||||
const other = await fixture.service.newSession({ cwd: "/other", mcpServers: [] })
|
||||
|
||||
expect(first.map((session) => session.sessionId).toSorted()).toEqual(["ses_1", "ses_2"])
|
||||
expect(other.sessionId).toBe("ses_3")
|
||||
expect(currentValue(first[0], "model")).toBe("test/test-model")
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
[
|
||||
"/api/model",
|
||||
"/api/model/default",
|
||||
"/api/agent",
|
||||
"/api/command",
|
||||
].map((path) =>
|
||||
fixture.requests
|
||||
.filter((request) => request.path === path)
|
||||
.map((request) => request.query["location[directory]"]),
|
||||
),
|
||||
).toEqual([
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.method === "POST" && request.path === "/api/session")
|
||||
.map((request) => request.body),
|
||||
).toEqual([
|
||||
{
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model", variant: "default" },
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
{
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model", variant: "default" },
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
{
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model", variant: "default" },
|
||||
location: { directory: "/other" },
|
||||
},
|
||||
])
|
||||
expect(
|
||||
fixture.updates.map((item) =>
|
||||
item.update.sessionUpdate === "available_commands_update"
|
||||
? item.update.availableCommands.map((command) => command.name)
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
["review"],
|
||||
["review"],
|
||||
["review"],
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["empty", "missing the default"])(
|
||||
"retries when the model list is %s but the default is ready",
|
||||
async (initial) => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (
|
||||
request.path === "/api/model" &&
|
||||
context.requests.filter((request) => request.path === "/api/model").length === 1
|
||||
) {
|
||||
return Response.json({
|
||||
location: {
|
||||
directory: "/workspace",
|
||||
project: { id: "global", directory: "/workspace", canonical: "/workspace" },
|
||||
},
|
||||
data: initial === "empty" ? [] : [secondModel],
|
||||
})
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_ready") })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const model = requireSelectOption(session.configOptions, "model")
|
||||
const choices = flattenSelectOptions(model).map((option) => option.value)
|
||||
|
||||
expect(choices).toContain("test/second-model")
|
||||
expect(choices).toContain("test/test-model")
|
||||
expect(model.currentValue).toBe("test/test-model")
|
||||
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(2)
|
||||
},
|
||||
)
|
||||
|
||||
test("does not cache a failed catalog load", async () => {
|
||||
let modelCalls = 0
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.path === "/api/model") {
|
||||
modelCalls++
|
||||
if (modelCalls === 1) {
|
||||
return Response.json(
|
||||
{ name: "ModelsNotReadyError", data: { message: "catalog is warming" } },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_retry") })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const failure = await fixture.service
|
||||
.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ name: "ModelsNotReadyError" })
|
||||
const retried = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(retried.sessionId).toBe("ses_retry")
|
||||
expect(modelCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("switches model, effort, and mode against the warm catalog", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_config") })
|
||||
}
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
(request.path === "/api/session/ses_config/model" || request.path === "/api/session/ses_config/agent")
|
||||
) {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const selectedModel = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
const selectedEffort = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "medium",
|
||||
})
|
||||
const selectedMode = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
})
|
||||
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
||||
|
||||
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
||||
expect(currentValue(selectedModel, "effort")).toBe("default")
|
||||
expect(currentValue(selectedEffort, "effort")).toBe("medium")
|
||||
expect(currentValue(selectedMode, "mode")).toBe("plan")
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path === "/api/session/ses_config/model")
|
||||
.map((request) => request.body),
|
||||
).toEqual([
|
||||
{ model: { providerID: "test", id: secondModel.id } },
|
||||
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path === "/api/session/ses_config/agent")
|
||||
.map((request) => request.body),
|
||||
).toEqual([{ agent: "plan" }, { agent: "build" }])
|
||||
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(1)
|
||||
|
||||
const invalidEffort = await fixture.service
|
||||
.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "maximum",
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
const invalidMode = await fixture.service
|
||||
.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: "missing",
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
const invalidConfig = await fixture.service
|
||||
.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "missing",
|
||||
value: "value",
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
expect(invalidEffort).toMatchObject({ _tag: "ACPInvalidEffortError" })
|
||||
expect(invalidMode).toMatchObject({ _tag: "ACPInvalidModeError" })
|
||||
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
|
||||
})
|
||||
|
||||
test("converts MCP configs and deduplicates registrations per session and config", async () => {
|
||||
const local: McpServer = {
|
||||
name: "tools",
|
||||
command: "bun",
|
||||
args: ["server.ts"],
|
||||
env: [{ name: "TOKEN", value: "x" }],
|
||||
}
|
||||
const changed: McpServer = { ...local, args: ["changed.ts"] }
|
||||
const remote: McpServer = {
|
||||
type: "http",
|
||||
name: "docs",
|
||||
url: "https://example.com/mcp",
|
||||
headers: [{ name: "Authorization", value: "Bearer x" }],
|
||||
}
|
||||
let created = 0
|
||||
const mcp = "/api/experimental/mcp/"
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
created++
|
||||
return Response.json({ data: makeSession(`ses_${created}`) })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_1") {
|
||||
return Response.json({ data: makeSession("ses_1") })
|
||||
}
|
||||
if (request.method === "PUT" && request.path.startsWith(mcp)) {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
|
||||
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
|
||||
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
|
||||
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
|
||||
|
||||
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith(mcp))
|
||||
expect(adds).toHaveLength(4)
|
||||
expect(adds.filter((request) => request.path === `${mcp}tools`).map((request) => request.body)).toEqual([
|
||||
{
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["bun", "server.ts"],
|
||||
environment: { TOKEN: "x" },
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["bun", "changed.ts"],
|
||||
environment: { TOKEN: "x" },
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["bun", "server.ts"],
|
||||
environment: { TOKEN: "x" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(adds.find((request) => request.path === `${mcp}docs`)?.body).toEqual({
|
||||
config: {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { Authorization: "Bearer x" },
|
||||
oauth: false,
|
||||
},
|
||||
})
|
||||
expect(adds.map((request) => request.query)).toEqual([
|
||||
{ "location[directory]": "/workspace" },
|
||||
{ "location[directory]": "/workspace" },
|
||||
{ "location[directory]": "/workspace" },
|
||||
{ "location[directory]": "/workspace" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function currentValue(
|
||||
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
|
||||
id: string,
|
||||
) {
|
||||
return result?.configOptions?.find((option) => option.id === id)?.currentValue
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
OpenCode,
|
||||
type AgentInfo,
|
||||
type CommandInfo,
|
||||
type ModelInfo,
|
||||
type ModelRef,
|
||||
type SessionInfo,
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { ACPService } from "../../src/acp/service"
|
||||
|
||||
export type FixtureRequest = {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly query: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
export type FixtureContext = {
|
||||
readonly requests: FixtureRequest[]
|
||||
send(event: unknown): void
|
||||
}
|
||||
|
||||
type FixtureHandler = (
|
||||
request: FixtureRequest,
|
||||
context: FixtureContext,
|
||||
) => Response | undefined | Promise<Response | undefined>
|
||||
|
||||
type FixtureOptions = {
|
||||
readonly fetch?: FixtureHandler
|
||||
readonly models?: readonly ModelInfo[]
|
||||
readonly defaultModel?: ModelInfo
|
||||
readonly agents?: readonly AgentInfo[]
|
||||
readonly commands?: readonly CommandInfo[]
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
id: "test-model",
|
||||
modelID: "test-model",
|
||||
providerID: "test",
|
||||
name: "Test Model",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [{ id: "default" }, { id: "high" }],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
} satisfies ModelInfo
|
||||
|
||||
export const secondModel = {
|
||||
id: "second-model",
|
||||
modelID: "second-model",
|
||||
providerID: "test",
|
||||
name: "Second Model",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [{ id: "low" }, { id: "medium" }],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 200_000, output: 20_000 },
|
||||
} satisfies ModelInfo
|
||||
|
||||
export const buildAgent = {
|
||||
id: "build",
|
||||
name: "Build",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
|
||||
export const planAgent = {
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Plan first",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
|
||||
export const reviewCommand = {
|
||||
name: "review",
|
||||
description: "Review changes",
|
||||
} satisfies CommandInfo
|
||||
|
||||
export function makeSession(
|
||||
id: string,
|
||||
input: {
|
||||
readonly cwd?: string
|
||||
readonly agent?: string
|
||||
readonly model?: ModelRef
|
||||
readonly cost?: number
|
||||
readonly tokens?: TokenUsageInfo
|
||||
readonly time?: SessionInfo["time"]
|
||||
readonly title?: string
|
||||
} = {},
|
||||
): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
projectID: "global",
|
||||
agent: input.agent ?? "build",
|
||||
model: input.model ?? { providerID: "test", id: "test-model", variant: "default" },
|
||||
cost: input.cost ?? 0,
|
||||
tokens: input.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: input.time ?? { created: 0, updated: 0 },
|
||||
title: input.title ?? `Session ${id}`,
|
||||
location: { directory: input.cwd ?? "/workspace" },
|
||||
}
|
||||
}
|
||||
|
||||
export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
const requests: FixtureRequest[] = []
|
||||
const updates: SessionNotification[] = []
|
||||
const encoder = new TextEncoder()
|
||||
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const models = options.models ?? [testModel, secondModel]
|
||||
const context: FixtureContext = {
|
||||
requests,
|
||||
send(event) {
|
||||
if (!eventController) throw new Error("ACP fixture has no active event stream")
|
||||
eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
},
|
||||
}
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(raw) {
|
||||
const url = new URL(raw.url)
|
||||
const request: FixtureRequest = {
|
||||
method: raw.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
body: raw.method === "GET" || raw.method === "HEAD" ? undefined : await raw.json().catch(() => undefined),
|
||||
}
|
||||
requests.push(request)
|
||||
const response = await options.fetch?.(request, context)
|
||||
if (response) return response
|
||||
|
||||
const directory = request.query["location[directory]"] ?? "/workspace"
|
||||
const location = { directory, project: { id: "global", directory } }
|
||||
if (request.path === "/api/event") {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value
|
||||
eventController = value
|
||||
context.send({ id: "evt_connected", type: "server.connected", data: {} })
|
||||
},
|
||||
cancel() {
|
||||
if (eventController === controller) eventController = undefined
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
if (request.path === "/api/model") return Response.json({ location, data: models })
|
||||
if (request.path === "/api/model/default") {
|
||||
return Response.json({ location, data: options.defaultModel ?? models[0] ?? null })
|
||||
}
|
||||
if (request.path === "/api/agent") {
|
||||
return Response.json({ location, data: options.agents ?? [buildAgent, planAgent] })
|
||||
}
|
||||
if (request.path === "/api/command") {
|
||||
return Response.json({ location, data: options.commands ?? [reviewCommand] })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const service = ACPService.make({
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
connection: {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
service,
|
||||
requests,
|
||||
updates,
|
||||
async [Symbol.asyncDispose]() {
|
||||
eventController?.close()
|
||||
await server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
|
||||
describe("acp service lifecycle", () => {
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
|
||||
await using fixture = makeACPFixture({
|
||||
models: [model],
|
||||
defaultModel: model,
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_default_variant", {
|
||||
model: { providerID: model.providerID, id: model.id },
|
||||
}),
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(fixture.requests).toContainEqual({
|
||||
method: "POST",
|
||||
path: "/api/session",
|
||||
query: {},
|
||||
body: {
|
||||
location: { directory: "/workspace" },
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "second-model" },
|
||||
},
|
||||
})
|
||||
expect(currentValue(created, "effort")).toBe("default")
|
||||
})
|
||||
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_loaded") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_loaded", {
|
||||
cwd: "/workspace",
|
||||
agent: "plan",
|
||||
model: { providerID: "test", id: secondModel.id, variant: "medium" },
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_resume") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_resume", {
|
||||
cwd: "/workspace",
|
||||
agent: "plan",
|
||||
model: { providerID: "test", id: secondModel.id, variant: "low" },
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_loaded/fork") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_fork", {
|
||||
cwd: "/workspace",
|
||||
agent: "plan",
|
||||
model: { providerID: "test", id: secondModel.id, variant: "medium" },
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_loaded/message") {
|
||||
if (request.query.cursor === "messages-2") {
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "hi there" }],
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
}
|
||||
return Response.json({
|
||||
data: [{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } }],
|
||||
cursor: { next: "messages-2" },
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_fork/message") {
|
||||
return Response.json({
|
||||
data: [{ id: "msg_fork", type: "user", text: "forked", time: { created: 2 } }],
|
||||
cursor: {},
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const loaded = await fixture.service.loadSession({
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_loaded",
|
||||
mcpServers: [],
|
||||
})
|
||||
const resumed = await fixture.service.resumeSession({
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_resume",
|
||||
mcpServers: [],
|
||||
})
|
||||
const forked = await fixture.service.forkSession({
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_loaded",
|
||||
mcpServers: [],
|
||||
})
|
||||
const mismatched = await fixture.service
|
||||
.loadSession({ cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })
|
||||
.catch((error: unknown) => error)
|
||||
expect(mismatched).toMatchObject({ _tag: "ACPSessionDirectoryMismatchError", cwd: "/elsewhere" })
|
||||
|
||||
expect(currentValue(loaded, "model")).toBe("test/second-model")
|
||||
expect(currentValue(loaded, "effort")).toBe("medium")
|
||||
expect(currentValue(loaded, "mode")).toBe("plan")
|
||||
expect(currentValue(resumed, "effort")).toBe("low")
|
||||
expect(forked.sessionId).toBe("ses_fork")
|
||||
expect(currentValue(forked, "effort")).toBe("medium")
|
||||
expect(
|
||||
fixture.updates.filter(
|
||||
(item) =>
|
||||
item.update.sessionUpdate === "user_message_chunk" || item.update.sessionUpdate === "agent_message_chunk",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
sessionId: "ses_loaded",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg_user",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "ses_loaded",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_assistant",
|
||||
content: { type: "text", text: "hi there" },
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "ses_fork",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg_fork",
|
||||
content: { type: "text", text: "forked" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path.endsWith("/message"))
|
||||
.map((request) => ({ path: request.path, query: request.query })),
|
||||
).toEqual([
|
||||
{
|
||||
path: "/api/session/ses_loaded/message",
|
||||
query: { limit: "200", order: "asc" },
|
||||
},
|
||||
{
|
||||
path: "/api/session/ses_loaded/message",
|
||||
query: { limit: "200", cursor: "messages-2" },
|
||||
},
|
||||
{
|
||||
path: "/api/session/ses_fork/message",
|
||||
query: { limit: "200", order: "asc" },
|
||||
},
|
||||
])
|
||||
expect(fixture.requests).toContainEqual({
|
||||
method: "POST",
|
||||
path: "/api/session/ses_loaded/fork",
|
||||
query: {},
|
||||
body: {},
|
||||
})
|
||||
})
|
||||
|
||||
test("lists server-backed pages and forwards cwd and cursor", async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) =>
|
||||
makeSession(`ses_${100 - index}`, {
|
||||
cwd: "/workspace",
|
||||
time: { created: index, updated: 100_000 - index },
|
||||
title: `Session ${100 - index}`,
|
||||
}),
|
||||
)
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method !== "GET" || request.path !== "/api/session") return undefined
|
||||
if (request.query.cursor === "page-2") {
|
||||
return Response.json({
|
||||
data: [makeSession("ses_0", { cwd: "/workspace", time: { created: 0, updated: 1 } })],
|
||||
cursor: {},
|
||||
})
|
||||
}
|
||||
return Response.json({ data: firstPage, cursor: { next: "page-2" } })
|
||||
},
|
||||
})
|
||||
|
||||
const first = await fixture.service.listSessions({ cwd: "/workspace" })
|
||||
const second = await fixture.service.listSessions({ cwd: "/workspace", cursor: first.nextCursor })
|
||||
|
||||
expect(first.sessions).toHaveLength(100)
|
||||
expect(first.sessions[0]).toEqual({
|
||||
sessionId: "ses_100",
|
||||
cwd: "/workspace",
|
||||
title: "Session 100",
|
||||
updatedAt: new Date(100_000).toISOString(),
|
||||
})
|
||||
expect(first.nextCursor).toBe("page-2")
|
||||
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
|
||||
expect(second.nextCursor).toBeUndefined()
|
||||
expect(
|
||||
fixture.requests.filter((request) => request.path === "/api/session").map((request) => request.query),
|
||||
).toEqual([
|
||||
{ limit: "100", order: "desc", directory: "/workspace" },
|
||||
{ limit: "100", order: "desc", directory: "/workspace", cursor: "page-2" },
|
||||
])
|
||||
})
|
||||
|
||||
test("cancel preserves the attachment while close removes it and surfaces interrupt failures", async () => {
|
||||
const interrupt = { fail: true }
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_lifecycle") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/missing/interrupt") {
|
||||
return Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID: "missing", message: "session not found" },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
if (request.method === "POST" && request.path.endsWith("/interrupt")) {
|
||||
return interrupt.fail ? new Response(null, { status: 500 }) : Response.json({ interrupted: false })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
await fixture.service.cancel({ sessionId: created.sessionId })
|
||||
const updated = await fixture.service.setSessionConfigOption({
|
||||
sessionId: created.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
})
|
||||
|
||||
expect(currentValue(updated, "effort")).toBe("high")
|
||||
const failed = await fixture.service.closeSession({ sessionId: created.sessionId }).catch((error: unknown) => error)
|
||||
expect(failed).toMatchObject({ name: "ClientError", reason: "UnexpectedStatus" })
|
||||
interrupt.fail = false
|
||||
expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
|
||||
const missing = await fixture.service
|
||||
.setSessionConfigOption({
|
||||
sessionId: created.sessionId,
|
||||
configId: "effort",
|
||||
value: "default",
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: created.sessionId })
|
||||
expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
|
||||
expect(
|
||||
fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
|
||||
).toEqual([
|
||||
"/api/session/ses_lifecycle/interrupt",
|
||||
"/api/session/ses_lifecycle/interrupt",
|
||||
"/api/session/ses_lifecycle/interrupt",
|
||||
"/api/session/missing/interrupt",
|
||||
])
|
||||
})
|
||||
|
||||
test("deletes sessions from backing and local storage", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_delete") })
|
||||
}
|
||||
if (request.method === "DELETE" && request.path === "/api/session/ses_delete") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({})
|
||||
expect(fixture.requests).toContainEqual({
|
||||
method: "DELETE",
|
||||
path: "/api/session/ses_delete",
|
||||
query: {},
|
||||
body: undefined,
|
||||
})
|
||||
const missing = await fixture.service
|
||||
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
|
||||
.catch((error: unknown) => error)
|
||||
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
|
||||
})
|
||||
})
|
||||
|
||||
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
|
||||
return result.configOptions?.find((option) => option.id === id)?.currentValue
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
|
||||
|
||||
describe("acp service prompt routing and usage", () => {
|
||||
test("routes slash commands and compact through their session endpoints", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_routes") })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_routes") {
|
||||
return Response.json({ data: makeSession("ses_routes") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
|
||||
const id = requestID(request)
|
||||
completeTurn(context, "ses_routes", {
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_routes", inboxID: id },
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const commandResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/review now" }],
|
||||
})
|
||||
const compactResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
})
|
||||
|
||||
expect([commandResult.stopReason, compactResult.stopReason]).toEqual(["end_turn", "end_turn"])
|
||||
const command = fixture.requests.find((request) => request.path === "/api/session/ses_routes/command")
|
||||
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
|
||||
expect(command?.body).toMatchObject({
|
||||
name: "review",
|
||||
text: "now",
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
})
|
||||
expect(compact?.body).toMatchObject({ id: expect.any(String) })
|
||||
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
|
||||
})
|
||||
|
||||
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
|
||||
const assistantTokens = {
|
||||
input: 100,
|
||||
output: 40,
|
||||
reasoning: 7,
|
||||
cache: { read: 11, write: 13 },
|
||||
}
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_usage") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_usage/model") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_usage/prompt") {
|
||||
const id = requestID(request)
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_usage", inboxID: id },
|
||||
})
|
||||
context.send({
|
||||
id: "evt_step",
|
||||
type: "session.step.ended",
|
||||
data: {
|
||||
sessionID: "ses_usage",
|
||||
assistantMessageID: "msg_assistant",
|
||||
finish: "stop",
|
||||
cost: 0.5,
|
||||
tokens: assistantTokens,
|
||||
},
|
||||
})
|
||||
context.send({
|
||||
id: "evt_done",
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_usage" },
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_usage/message/msg_assistant") {
|
||||
return Response.json({
|
||||
data: {
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: secondModel.id },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
finish: "stop",
|
||||
tokens: assistantTokens,
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_usage") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_usage", {
|
||||
model: { providerID: "test", id: secondModel.id },
|
||||
cost: 3.5,
|
||||
tokens: { input: 120, output: 50, reasoning: 8, cache: { read: 30, write: 4 } },
|
||||
}),
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
|
||||
const response = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
thoughtTokens: 7,
|
||||
cachedReadTokens: 11,
|
||||
cachedWriteTokens: 13,
|
||||
totalTokens: 171,
|
||||
},
|
||||
_meta: {},
|
||||
})
|
||||
expect(fixture.updates.filter((item) => item.update.sessionUpdate === "usage_update")).toEqual([
|
||||
{
|
||||
sessionId: "ses_usage",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 171,
|
||||
size: 200_000,
|
||||
cost: { amount: 3.5, currency: "USD" },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("does not fail a completed prompt when the usage refresh fails", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_usage_failure") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_usage_failure/prompt") {
|
||||
const id = requestID(request)
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_usage_failure", inboxID: id },
|
||||
})
|
||||
context.send({
|
||||
id: "evt_step_failure",
|
||||
type: "session.step.ended",
|
||||
data: {
|
||||
sessionID: "ses_usage_failure",
|
||||
assistantMessageID: "msg_usage_failure",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
})
|
||||
context.send({
|
||||
id: "evt_done_failure",
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_usage_failure" },
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_usage_failure/message/msg_usage_failure") {
|
||||
return Response.json({
|
||||
data: {
|
||||
id: "msg_usage_failure",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
content: [],
|
||||
finish: "stop",
|
||||
tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_usage_failure") {
|
||||
return new Response(null, { status: 500 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const response = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(fixture.updates.some((item) => item.update.sessionUpdate === "usage_update")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function requestID(request: FixtureRequest) {
|
||||
if (!request.body || typeof request.body !== "object") throw new Error(`missing body for ${request.path}`)
|
||||
const id = "id" in request.body ? request.body.id : undefined
|
||||
if (typeof id !== "string") throw new Error(`missing prompt id for ${request.path}`)
|
||||
return id
|
||||
}
|
||||
|
||||
function completeTurn(context: FixtureContext, sessionID: string, start: unknown) {
|
||||
context.send(start)
|
||||
context.send({
|
||||
id: `evt_done_${sessionID}`,
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID },
|
||||
})
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { ACPService } from "../../src/acp/service"
|
||||
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
|
||||
|
||||
describe("acp service", () => {
|
||||
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
||||
const requests: Array<{ method: string; path: string; body?: unknown }> = []
|
||||
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests.push({
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
|
||||
})
|
||||
const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
|
||||
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
|
||||
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
|
||||
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
|
||||
if (url.pathname === "/api/command")
|
||||
return Response.json({ location, data: [{ name: "review", template: "" }] })
|
||||
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
|
||||
if (url.pathname === "/api/experimental/mcp/docs" && request.method === "PUT")
|
||||
return new Response(null, { status: 204 })
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const service = ACPService.make({
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
connection: {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const initialized = await service.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
|
||||
clientInfo: { name: "test", version: "1" },
|
||||
})
|
||||
const result = await service.newSession({
|
||||
cwd: "/workspace",
|
||||
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
|
||||
})
|
||||
expect(result.sessionId).toBe("ses_acp")
|
||||
expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true })
|
||||
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||
expect(requests).toContainEqual({
|
||||
method: "PUT",
|
||||
path: "/api/experimental/mcp/docs",
|
||||
body: {
|
||||
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
|
||||
},
|
||||
})
|
||||
expect(updates.at(-1)).toMatchObject({
|
||||
sessionId: "ses_acp",
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "review", description: "" }],
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const model = {
|
||||
id: "test-model",
|
||||
modelID: "test-model",
|
||||
providerID: "test",
|
||||
name: "Test Model",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [{ id: "default" }, { id: "high" }],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active" as const,
|
||||
enabled: true,
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
}
|
||||
|
||||
const agent = {
|
||||
id: "build",
|
||||
name: "Build",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary" as const,
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "ses_acp",
|
||||
projectID: "global",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model", variant: "default" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "New session",
|
||||
location: { directory: "/workspace" },
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { currentValue } from "./select-options"
|
||||
import { makeSession, rpcError, secondModel, startSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp session lifecycle over the wire", () => {
|
||||
test("initialize advertises capabilities and terminal auth only when the client asks", async () => {
|
||||
await using acp = await startWire()
|
||||
|
||||
const plain = await acp.initialize()
|
||||
const terminal = await acp.initialize({ terminalAuth: true, childSessionUpdates: true })
|
||||
|
||||
expect(plain).toMatchObject({
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { "opencode/child-session-updates": true },
|
||||
},
|
||||
agentInfo: { name: "OpenCode" },
|
||||
})
|
||||
expect(plain.authMethods).toEqual([
|
||||
{ id: "opencode-login", name: "Login with opencode", description: "Run `opencode auth login` in the terminal" },
|
||||
])
|
||||
expect(terminal.authMethods?.[0]?._meta).toEqual({
|
||||
"terminal-auth": { command: "opencode", args: ["auth", "login"], label: "OpenCode Login" },
|
||||
})
|
||||
expect(await acp.request("authenticate", { methodId: "opencode-login" })).toEqual({})
|
||||
expect(await rpcError(acp.request("authenticate", { methodId: "missing" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { methodId: "missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.commands = [{ name: "review" }]
|
||||
await acp.initialize()
|
||||
|
||||
const result = await acp.newSession("/workspace", [
|
||||
{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] },
|
||||
])
|
||||
|
||||
expect(acp.server.sessions.get(result.sessionId)?.location.directory).toBe("/workspace")
|
||||
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||
expect(acp.server.mcp).toEqual([
|
||||
{
|
||||
name: "docs",
|
||||
directory: "/workspace",
|
||||
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
|
||||
},
|
||||
])
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).toEqual({
|
||||
sessionId: result.sessionId,
|
||||
update: { sessionUpdate: "available_commands_update", availableCommands: [{ name: "review", description: "" }] },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [{ ...secondModel, variants: [{ id: "none" }, { id: "high" }] }]
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.newSession()
|
||||
|
||||
expect(currentValue(created, "effort")).toBe("default")
|
||||
expect(acp.server.sessions.get(created.sessionId)?.model).toBeUndefined()
|
||||
expect(acp.server.selections).toEqual([])
|
||||
})
|
||||
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
await using acp = await startWire()
|
||||
const history = Array.from({ length: 201 }, (_, index) => ({
|
||||
id: `msg_${index}`,
|
||||
type: "user" as const,
|
||||
text: `message ${index}`,
|
||||
time: { created: index },
|
||||
}))
|
||||
acp.server.sessions.set(
|
||||
"ses_loaded",
|
||||
makeSession("ses_loaded", {
|
||||
agent: "plan",
|
||||
model: { providerID: "test", id: secondModel.id, variant: "medium" },
|
||||
}),
|
||||
)
|
||||
acp.server.messages.set("ses_loaded", history)
|
||||
acp.server.sessions.set(
|
||||
"ses_resume",
|
||||
makeSession("ses_resume", { agent: "plan", model: { providerID: "test", id: secondModel.id, variant: "low" } }),
|
||||
)
|
||||
acp.server.messages.set("ses_resume", [{ id: "msg_resume", type: "user", text: "hidden", time: { created: 1 } }])
|
||||
await acp.initialize()
|
||||
|
||||
const loaded = await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })
|
||||
const resumed = await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_resume", mcpServers: [] })
|
||||
const forked = await acp.request("session/fork", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })
|
||||
|
||||
expect(
|
||||
await rpcError(acp.request("session/load", { cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded", cwd: "/elsewhere" } })
|
||||
expect(
|
||||
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_missing", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_missing" } })
|
||||
expect(currentValue(loaded, "model")).toBe("test/second-model")
|
||||
expect(currentValue(loaded, "effort")).toBe("medium")
|
||||
expect(currentValue(loaded, "mode")).toBe("plan")
|
||||
expect(currentValue(resumed, "effort")).toBe("low")
|
||||
expect(currentValue(forked, "effort")).toBe("medium")
|
||||
expect(acp.server.sessions.has(forked.sessionId)).toBe(true)
|
||||
const replayed = (sessionId: string) =>
|
||||
acp.updates.flatMap((item) =>
|
||||
item.sessionId === sessionId && item.update.sessionUpdate === "user_message_chunk"
|
||||
? [item.update.messageId]
|
||||
: [],
|
||||
)
|
||||
expect(replayed("ses_loaded")).toEqual(history.map((message) => message.id))
|
||||
expect(replayed(forked.sessionId)).toEqual(history.map((message) => message.id))
|
||||
expect(replayed("ses_resume")).toEqual([])
|
||||
expect(
|
||||
acp.updates.find((item) => item.sessionId === "ses_loaded" && item.update.sessionUpdate === "user_message_chunk")
|
||||
?.update,
|
||||
).toEqual({
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg_0",
|
||||
content: { type: "text", text: "message 0" },
|
||||
})
|
||||
})
|
||||
|
||||
test("lists server-backed pages for the requested cwd", async () => {
|
||||
await using acp = await startWire()
|
||||
Array.from({ length: 101 }, (_, index) =>
|
||||
makeSession(`ses_${index}`, { time: { created: index, updated: 1_000 + index } }),
|
||||
).forEach((session) => acp.server.sessions.set(session.id, session))
|
||||
acp.server.sessions.set(
|
||||
"ses_other",
|
||||
makeSession("ses_other", { cwd: "/other", time: { created: 0, updated: 9_999 } }),
|
||||
)
|
||||
await acp.initialize()
|
||||
|
||||
const first = await acp.request("session/list", { cwd: "/workspace" })
|
||||
const second = await acp.request("session/list", { cwd: "/workspace", cursor: first.nextCursor })
|
||||
|
||||
expect(first.sessions).toHaveLength(100)
|
||||
expect(first.sessions[0]).toEqual({
|
||||
sessionId: "ses_100",
|
||||
cwd: "/workspace",
|
||||
title: "Session ses_100",
|
||||
updatedAt: new Date(1_100).toISOString(),
|
||||
})
|
||||
expect(first.nextCursor).toBeDefined()
|
||||
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
|
||||
expect(second.nextCursor).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cancel keeps an idle session attached while close detaches it", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
expect((await acp.prompt(acp.sessionId, "after cancel")).stopReason).toBe("end_turn")
|
||||
|
||||
expect(await acp.request("session/close", { sessionId: acp.sessionId })).toEqual({})
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "after close"))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: acp.sessionId },
|
||||
})
|
||||
expect(await acp.request("session/close", { sessionId: "missing" })).toEqual({})
|
||||
})
|
||||
|
||||
test("deletes sessions from backing and local storage", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
|
||||
expect(acp.server.sessions.has(acp.sessionId)).toBe(false)
|
||||
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: acp.sessionId, configId: "effort", value: "high" }),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: acp.sessionId } })
|
||||
})
|
||||
|
||||
test("converts MCP configs and deduplicates registrations per session and config", async () => {
|
||||
const local: McpServer = {
|
||||
name: "tools",
|
||||
command: "bun",
|
||||
args: ["server.ts"],
|
||||
env: [{ name: "TOKEN", value: "x" }],
|
||||
}
|
||||
const changed: McpServer = { ...local, args: ["changed.ts"] }
|
||||
const remote: McpServer = {
|
||||
type: "http",
|
||||
name: "docs",
|
||||
url: "https://example.com/mcp",
|
||||
headers: [{ name: "Authorization", value: "Bearer x" }],
|
||||
}
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const first = await acp.newSession("/workspace", [local, local, remote])
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [local, remote] })
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [changed] })
|
||||
await acp.newSession("/workspace", [local])
|
||||
|
||||
const localConfig = (args: string[]) => ({
|
||||
name: "tools",
|
||||
directory: "/workspace",
|
||||
config: { type: "local", command: ["bun", ...args], environment: { TOKEN: "x" } },
|
||||
})
|
||||
expect(acp.server.mcp).toHaveLength(4)
|
||||
expect(acp.server.mcp.filter((item) => item.name === "tools")).toEqual([
|
||||
localConfig(["server.ts"]),
|
||||
localConfig(["changed.ts"]),
|
||||
localConfig(["server.ts"]),
|
||||
])
|
||||
expect(acp.server.mcp.find((item) => item.name === "docs")).toEqual({
|
||||
name: "docs",
|
||||
directory: "/workspace",
|
||||
config: { type: "remote", url: "https://example.com/mcp", headers: { Authorization: "Bearer x" }, oauth: false },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise"
|
||||
|
||||
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
|
||||
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
|
||||
|
||||
type RequestRecord = {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly body?: unknown
|
||||
}
|
||||
|
||||
type FixtureOptions = {
|
||||
readonly onPrompt?: (input: {
|
||||
readonly sessionID: string
|
||||
readonly id: string
|
||||
readonly body: unknown
|
||||
readonly signal: AbortSignal
|
||||
readonly send: (event: unknown) => void
|
||||
}) => void | Promise<void>
|
||||
readonly onInterrupt?: (input: {
|
||||
readonly sessionID: string
|
||||
readonly send: (event: unknown) => void
|
||||
}) => boolean | Promise<boolean>
|
||||
readonly onPermissionReply?: (input: {
|
||||
readonly sessionID: string
|
||||
readonly requestID: string
|
||||
readonly reply: string
|
||||
readonly body: unknown
|
||||
readonly send: (event: unknown) => void
|
||||
}) => void | Promise<void>
|
||||
readonly onFormCancel?: (input: {
|
||||
readonly sessionID: string
|
||||
readonly formID: string
|
||||
readonly send: (event: unknown) => void
|
||||
}) => void | Promise<void>
|
||||
}
|
||||
|
||||
const ids = { next: 0 }
|
||||
|
||||
export function durableEvent<Type extends DurableEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<DurableEvent, { type: Type }>["data"],
|
||||
) {
|
||||
ids.next++
|
||||
return {
|
||||
id: `evt_${ids.next}`,
|
||||
created: ids.next,
|
||||
type,
|
||||
durable: { aggregateID: "test", seq: ids.next, version: 1 },
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<EphemeralEvent, { type: Type }>["data"],
|
||||
) {
|
||||
ids.next++
|
||||
return { id: `evt_${ids.next}`, created: ids.next, type, data }
|
||||
}
|
||||
|
||||
export function createSseFixture(options: FixtureOptions = {}) {
|
||||
const encoder = new TextEncoder()
|
||||
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const requests: RequestRecord[] = []
|
||||
const messages = new Map<string, SessionMessageInfo>()
|
||||
|
||||
const send = (event: unknown) => {
|
||||
for (const stream of streams) {
|
||||
try {
|
||||
stream.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
} catch {
|
||||
streams.delete(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
const body = request.method === "GET" ? undefined : await request.json().catch(() => undefined)
|
||||
requests.push({ method: request.method, path: url.pathname, ...(body === undefined ? {} : { body }) })
|
||||
|
||||
if (url.pathname === "/api/event") {
|
||||
const state: { stream?: ReadableStreamDefaultController<Uint8Array> } = {}
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(stream) {
|
||||
state.stream = stream
|
||||
streams.add(stream)
|
||||
stream.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
|
||||
),
|
||||
)
|
||||
},
|
||||
cancel() {
|
||||
if (state.stream) streams.delete(state.stream)
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
|
||||
const prompt = /^\/api\/session\/([^/]+)\/prompt$/.exec(url.pathname)
|
||||
if (prompt?.[1]) {
|
||||
const id = stringField(body, "id")
|
||||
if (!id) return new Response(null, { status: 400 })
|
||||
await options.onPrompt?.({
|
||||
sessionID: decodeURIComponent(prompt[1]),
|
||||
id,
|
||||
body,
|
||||
signal: request.signal,
|
||||
send,
|
||||
})
|
||||
return Response.json({ data: { text: stringField(body, "text") ?? "" } })
|
||||
}
|
||||
|
||||
const message = /^\/api\/session\/([^/]+)\/message\/([^/]+)$/.exec(url.pathname)
|
||||
if (message?.[1] && message[2]) {
|
||||
const sessionID = decodeURIComponent(message[1])
|
||||
const messageID = decodeURIComponent(message[2])
|
||||
return Response.json({
|
||||
data: messages.get(`${sessionID}/${messageID}`) ?? messages.get(messageID) ?? assistantMessage(messageID),
|
||||
})
|
||||
}
|
||||
|
||||
const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
|
||||
if (permission?.[1] && permission[2]) {
|
||||
const reply = stringField(body, "decision")
|
||||
if (!reply) return new Response(null, { status: 400 })
|
||||
await options.onPermissionReply?.({
|
||||
sessionID: decodeURIComponent(permission[1]),
|
||||
requestID: decodeURIComponent(permission[2]),
|
||||
reply,
|
||||
body,
|
||||
send,
|
||||
})
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)$/.exec(url.pathname)
|
||||
if (request.method === "DELETE" && form?.[1] && form[2]) {
|
||||
await options.onFormCancel?.({
|
||||
sessionID: decodeURIComponent(form[1]),
|
||||
formID: decodeURIComponent(form[2]),
|
||||
send,
|
||||
})
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
|
||||
if (interrupt?.[1]) {
|
||||
const interrupted =
|
||||
(await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })) ?? false
|
||||
return Response.json({ interrupted })
|
||||
}
|
||||
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
messages,
|
||||
requests,
|
||||
send,
|
||||
async stop() {
|
||||
for (const stream of streams) {
|
||||
try {
|
||||
stream.close()
|
||||
} catch {}
|
||||
}
|
||||
streams.clear()
|
||||
await server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTimeout<Value>(promise: Promise<Value>, message: string, milliseconds = 2_000) {
|
||||
const timeout = Promise.withResolvers<never>()
|
||||
const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds)
|
||||
try {
|
||||
return await Promise.race([promise, timeout.promise])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function stringField(value: unknown, key: string) {
|
||||
if (!value || typeof value !== "object") return undefined
|
||||
const field = (value as Record<string, unknown>)[key]
|
||||
return typeof field === "string" ? field : undefined
|
||||
}
|
||||
|
||||
function assistantMessage(id: string) {
|
||||
return {
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
content: [],
|
||||
finish: "stop",
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, completed: 2 },
|
||||
} satisfies SessionMessageInfo
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
InitializeResponse,
|
||||
NewSessionResponse,
|
||||
SessionConfigOption,
|
||||
SessionConfigSelectOption,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { InitializeResponse, NewSessionResponse } from "@agentclientprotocol/sdk"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -45,6 +40,8 @@ type Waiter = {
|
||||
}
|
||||
|
||||
export type AcpProcess = {
|
||||
readonly pid: number
|
||||
readonly exited: Promise<number>
|
||||
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
|
||||
readonly send: <T>(
|
||||
method: string,
|
||||
@@ -164,29 +161,6 @@ export function expectOk<T>(response: JsonRpcResponse<T>) {
|
||||
return response.result
|
||||
}
|
||||
|
||||
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
return options?.find(
|
||||
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
|
||||
option.id === id && option.type === "select",
|
||||
)
|
||||
}
|
||||
|
||||
export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
const option = selectConfigOption(options, id)
|
||||
if (!option) throw new Error(`Missing ACP config option: ${id}`)
|
||||
return option
|
||||
}
|
||||
|
||||
export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
|
||||
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
|
||||
}
|
||||
|
||||
export function alternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
|
||||
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
|
||||
if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
|
||||
return value
|
||||
}
|
||||
|
||||
function verifierConfig(llmUrl: string, skills?: string) {
|
||||
const model = {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
@@ -326,6 +300,8 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
|
||||
}
|
||||
|
||||
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 }),
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { resolve } from "node:path"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
reasoningDelta,
|
||||
startSession,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolCalled,
|
||||
toolFailed,
|
||||
toolProgress,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
turn,
|
||||
type ChildUpdate,
|
||||
} from "./wire-fixture"
|
||||
|
||||
describe("acp turn events over the wire", () => {
|
||||
test("isolates events from other sessions and other inputs", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
textDelta(sessionID, "msg_before", "before admission"),
|
||||
delivered("ses_other", id),
|
||||
delivered(sessionID, "input_other"),
|
||||
textDelta(sessionID, "msg_wrong_input", "wrong input"),
|
||||
delivered(sessionID, id),
|
||||
textDelta("ses_other", "msg_other", "other session"),
|
||||
textDelta(sessionID, "msg_accepted", "accepted"),
|
||||
stepEnded(sessionID, "msg_accepted"),
|
||||
succeeded("ses_other"),
|
||||
succeeded(sessionID),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_accepted",
|
||||
content: { type: "text", text: "accepted" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("streams ordered reasoning and text chunks before admission returns", async () => {
|
||||
const releaseAdmission = Promise.withResolvers<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("streams tool pending, progress, success, and failure updates", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_ok", "shell"),
|
||||
toolCalled(sessionID, "call_ok", { command: "printf done", workdir: "sub" }),
|
||||
toolProgress(sessionID, "call_ok", { phase: 1 }),
|
||||
toolSucceeded(sessionID, "call_ok", { exit: 0 }, "done"),
|
||||
toolStarted(sessionID, "call_fail", "read"),
|
||||
toolCalled(sessionID, "call_fail", { path: "/workspace/missing.ts" }),
|
||||
toolProgress(sessionID, "call_fail", { bytes: 0 }),
|
||||
toolFailed(sessionID, "call_fail", {
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
}),
|
||||
stepEnded(sessionID, "msg_tools"),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const updates = turnUpdates(acp.updates)
|
||||
expect(updates.map((item) => [item.update.sessionUpdate, toolStatus(item), toolCallID(item)])).toEqual([
|
||||
["tool_call", "pending", "call_ok"],
|
||||
["tool_call_update", "in_progress", "call_ok"],
|
||||
["tool_call_update", "in_progress", "call_ok"],
|
||||
["tool_call_update", "completed", "call_ok"],
|
||||
["tool_call", "pending", "call_fail"],
|
||||
["tool_call_update", "in_progress", "call_fail"],
|
||||
["tool_call_update", "in_progress", "call_fail"],
|
||||
["tool_call_update", "failed", "call_fail"],
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
title: "printf done",
|
||||
kind: "execute",
|
||||
locations: [{ path: resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
})
|
||||
expect(updates[2]?.update).not.toHaveProperty("content")
|
||||
expect(updates[3]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[7]?.update).toMatchObject({
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/missing.ts" }],
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "opening" } },
|
||||
{ type: "content", content: { type: "text", text: "not found" } },
|
||||
],
|
||||
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("cancels unsupported session forms so execution can continue", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_question",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "q0", title: "Choice", type: "string" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID, formID }) => [
|
||||
ephemeralEvent("form.cancelled", { sessionID, id: formID }),
|
||||
succeeded(sessionID),
|
||||
],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
|
||||
})
|
||||
|
||||
test.todo(
|
||||
"reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)",
|
||||
async () => {
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: /workspace/src/c.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "/workspace/src/a.ts", oldString: "a", newString: "b" }),
|
||||
toolSucceeded(sessionID, "call_edit", {}, "edited"),
|
||||
toolStarted(sessionID, "call_write", "write"),
|
||||
toolCalled(sessionID, "call_write", { path: "/workspace/src/b.ts", content: "b" }),
|
||||
toolSucceeded(sessionID, "call_write", {}, "written"),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
toolSucceeded(sessionID, "call_patch", {}, "patched"),
|
||||
),
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const locations = turnUpdates(acp.updates)
|
||||
.filter((item) => item.update.sessionUpdate === "tool_call_update")
|
||||
.map((item) => [
|
||||
toolCallID(item),
|
||||
toolStatus(item),
|
||||
"locations" in item.update ? item.update.locations : undefined,
|
||||
])
|
||||
expect(locations).toEqual([
|
||||
["call_edit", "in_progress", [{ path: "/workspace/src/a.ts" }]],
|
||||
["call_edit", "completed", [{ path: "/workspace/src/a.ts" }]],
|
||||
["call_write", "in_progress", [{ path: "/workspace/src/b.ts" }]],
|
||||
["call_write", "completed", [{ path: "/workspace/src/b.ts" }]],
|
||||
["call_patch", "in_progress", [{ path: "/workspace/src/c.ts" }]],
|
||||
["call_patch", "completed", [{ path: "/workspace/src/c.ts" }]],
|
||||
])
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
return updates.filter(
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
)
|
||||
}
|
||||
|
||||
function chunks(updates: readonly SessionNotification[]) {
|
||||
return updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "agent_message_chunk" || item.update.sessionUpdate === "agent_thought_chunk"
|
||||
? [
|
||||
[
|
||||
item.update.sessionUpdate,
|
||||
item.update.messageId,
|
||||
item.update.content.type === "text" ? item.update.content.text : undefined,
|
||||
],
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function childUpdateKind(item: ChildUpdate) {
|
||||
return item.type === "status" ? `status:${item.status}` : `update:${item.update.sessionUpdate}`
|
||||
}
|
||||
|
||||
function toolCallID(item: SessionNotification) {
|
||||
return "toolCallId" in item.update ? item.update.toolCallId : undefined
|
||||
}
|
||||
|
||||
function toolStatus(item: SessionNotification) {
|
||||
return "status" in item.update ? item.update.status : undefined
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
import {
|
||||
client,
|
||||
ndJsonStream,
|
||||
RequestError,
|
||||
type AgentNotificationMethod,
|
||||
type AgentNotificationParamsByMethod,
|
||||
type AgentRequestMethod,
|
||||
type AgentRequestParamsByMethod,
|
||||
type AgentRequestResponsesByMethod,
|
||||
type AnyMessage,
|
||||
type ContentBlock,
|
||||
type McpServer,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type WriteTextFileRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
OpenCode,
|
||||
type AgentInfo,
|
||||
type CommandInfo,
|
||||
type LocationRef,
|
||||
type ModelInfo,
|
||||
type ModelRef,
|
||||
type OpenCodeEvent,
|
||||
type SessionInfo,
|
||||
type SessionMessageInfo,
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import type { BunRequest } from "bun"
|
||||
import { Option, Schema } from "effect"
|
||||
import { ACP } from "../../src/acp/agent"
|
||||
|
||||
type DurableEvent = Extract<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 Hook<Input> = (input: Input) => Events | Promise<Events>
|
||||
|
||||
export type ServerRequest = {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly query: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
const Files = Schema.Array(Schema.Struct({ uri: Schema.String, name: Schema.optional(Schema.String) }))
|
||||
const Delivery = Schema.optional(Schema.String)
|
||||
const PromptBody = Schema.Struct({ id: Schema.String, text: Schema.String, files: Files, delivery: Delivery })
|
||||
const CommandBody = Schema.Struct({ name: Schema.String, text: Schema.String, files: Files, delivery: Delivery })
|
||||
const CompactBody = Schema.Struct({ id: Schema.String })
|
||||
const SyntheticBody = Schema.Struct({
|
||||
text: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
delivery: Delivery,
|
||||
resume: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
const CreateBody = Schema.Struct({ location: Schema.Struct({ directory: Schema.String }) })
|
||||
const ModelBody = Schema.Struct({
|
||||
model: Schema.Struct({ providerID: Schema.String, id: Schema.String, variant: Schema.optional(Schema.String) }),
|
||||
})
|
||||
const AgentBody = Schema.Struct({ agent: Schema.String })
|
||||
const ReplyBody = Schema.Struct({ decision: Schema.Literals(["once", "always", "reject"]) })
|
||||
const McpBody = Schema.Struct({ config: Schema.Unknown })
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
export type Submission =
|
||||
| ({ readonly kind: "prompt"; readonly sessionID: string } & typeof PromptBody.Type)
|
||||
| ({ readonly kind: "command"; readonly sessionID: string } & typeof CommandBody.Type)
|
||||
| ({ readonly kind: "compact"; readonly sessionID: string } & typeof CompactBody.Type)
|
||||
| ({ readonly kind: "synthetic"; readonly sessionID: string } & typeof SyntheticBody.Type)
|
||||
|
||||
type PromptSubmission = Extract<Submission, { readonly kind: "prompt" }>
|
||||
|
||||
export type Selection =
|
||||
| { readonly sessionID: string; readonly model: typeof ModelBody.Type.model }
|
||||
| { readonly sessionID: string; readonly agent: string }
|
||||
|
||||
const ChildSession = {
|
||||
rootSessionId: Schema.String,
|
||||
childSessionId: Schema.String,
|
||||
parentSessionId: Schema.String,
|
||||
depth: Schema.Number,
|
||||
title: Schema.optional(Schema.String),
|
||||
}
|
||||
const ChildUpdate = Schema.Union([
|
||||
Schema.Struct({
|
||||
...ChildSession,
|
||||
type: Schema.Literal("update"),
|
||||
update: Schema.StructWithRest(Schema.Struct({ sessionUpdate: Schema.String }), [
|
||||
Schema.Record(Schema.String, Schema.Unknown),
|
||||
]),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...ChildSession,
|
||||
type: Schema.Literal("status"),
|
||||
status: Schema.Literals(["created", "running", "completed", "failed", "interrupted"]),
|
||||
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
|
||||
}),
|
||||
])
|
||||
export type ChildUpdate = typeof ChildUpdate.Type
|
||||
|
||||
export type WireOptions = {
|
||||
readonly fetch?: (request: ServerRequest) => Response | undefined | Promise<Response | undefined>
|
||||
readonly onPrompt?: Hook<{
|
||||
readonly sessionID: string
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly signal: AbortSignal
|
||||
}>
|
||||
readonly onInterrupt?: Hook<{ readonly sessionID: string }>
|
||||
readonly onPermissionReply?: Hook<{
|
||||
readonly sessionID: string
|
||||
readonly requestID: string
|
||||
readonly decision: string
|
||||
}>
|
||||
readonly onFormCancel?: Hook<{ readonly sessionID: string; readonly formID: string }>
|
||||
readonly permission?: (
|
||||
request: RequestPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
) => RequestPermissionResponse | Promise<RequestPermissionResponse>
|
||||
}
|
||||
|
||||
type CatalogKind = "model" | "default" | "agent" | "command"
|
||||
|
||||
export type Catalog = {
|
||||
models: ModelInfo[]
|
||||
// Unset follows the first listed model.
|
||||
defaultModel?: ModelInfo | null
|
||||
agents: AgentInfo[]
|
||||
commands: CommandInfo[]
|
||||
}
|
||||
|
||||
export type InitializeOptions = {
|
||||
readonly writeTextFile?: boolean
|
||||
readonly childSessionUpdates?: boolean
|
||||
readonly terminalAuth?: boolean
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
id: "test-model",
|
||||
modelID: "test-model",
|
||||
providerID: "test",
|
||||
name: "Test Model",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [{ id: "default" }, { id: "high" }],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
} satisfies ModelInfo
|
||||
|
||||
export const secondModel = {
|
||||
id: "second-model",
|
||||
modelID: "second-model",
|
||||
providerID: "test",
|
||||
name: "Second Model",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [{ id: "low" }, { id: "medium" }],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 200_000, output: 20_000 },
|
||||
} satisfies ModelInfo
|
||||
|
||||
export const buildAgent = {
|
||||
id: "build",
|
||||
name: "Build",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
|
||||
export const planAgent = {
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Plan first",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
|
||||
export const reviewCommand = {
|
||||
name: "review",
|
||||
description: "Review changes",
|
||||
} satisfies CommandInfo
|
||||
|
||||
export function makeSession(
|
||||
id: string,
|
||||
input: {
|
||||
readonly cwd?: string
|
||||
readonly agent?: string
|
||||
readonly model?: ModelRef
|
||||
readonly cost?: number
|
||||
readonly time?: SessionInfo["time"]
|
||||
} = {},
|
||||
): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
projectID: "global",
|
||||
...(input.agent ? { agent: input.agent } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
cost: input.cost ?? 0,
|
||||
tokens: tokens(0),
|
||||
time: input.time ?? { created: 0, updated: 0 },
|
||||
title: `Session ${id}`,
|
||||
location: { directory: input.cwd ?? "/workspace" },
|
||||
}
|
||||
}
|
||||
|
||||
export function assistantMessage(id: string, input: Partial<AssistantMessage> = {}) {
|
||||
return {
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
content: [],
|
||||
finish: "stop",
|
||||
tokens: tokens(),
|
||||
time: { created: 1, completed: 2 },
|
||||
...input,
|
||||
} satisfies SessionMessageInfo
|
||||
}
|
||||
|
||||
export function tokens(value = 1): TokenUsageInfo {
|
||||
return { input: value, output: value, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
}
|
||||
|
||||
// The fake server stamps ids and sequence numbers when it sends an event.
|
||||
function durable<Version 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 })
|
||||
}
|
||||
|
||||
export const durableEvent = durable(1)
|
||||
const durableEventV2 = durable(2)
|
||||
|
||||
export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
|
||||
type: Type,
|
||||
data: EventData<Type>,
|
||||
location?: LocationRef,
|
||||
) {
|
||||
return { id: "", created: 0, type, data, ...(location ? { location } : {}) }
|
||||
}
|
||||
|
||||
export const delivered = (sessionID: string, inboxID: string) =>
|
||||
durableEvent("session.inbox.delivered", { sessionID, inboxID })
|
||||
|
||||
export const succeeded = (sessionID: string) => durableEvent("session.execution.succeeded", { sessionID })
|
||||
|
||||
export const interrupted = (sessionID: string) =>
|
||||
durableEvent("session.execution.interrupted", { sessionID, reason: "user" })
|
||||
|
||||
export const failed = (sessionID: string, error: EventData<"session.execution.failed">["error"]) =>
|
||||
durableEvent("session.execution.failed", { sessionID, error })
|
||||
|
||||
export function turn(sessionID: string, inboxID: string, ...events: OpenCodeEvent[]): OpenCodeEvent[] {
|
||||
return [delivered(sessionID, inboxID), ...events, succeeded(sessionID)]
|
||||
}
|
||||
|
||||
export const textDelta = (sessionID: string, assistantMessageID: string, delta: string, ordinal = 0) =>
|
||||
ephemeralEvent("session.text.delta", { sessionID, assistantMessageID, ordinal, delta })
|
||||
|
||||
export const reasoningDelta = (sessionID: string, assistantMessageID: string, delta: string, ordinal = 0) =>
|
||||
ephemeralEvent("session.reasoning.delta", { sessionID, assistantMessageID, ordinal, delta })
|
||||
|
||||
export const stepEnded = (
|
||||
sessionID: string,
|
||||
assistantMessageID: string,
|
||||
input: { readonly finish?: EventData<"session.step.ended">["finish"]; readonly tokens?: TokenUsageInfo } = {},
|
||||
) =>
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
finish: input.finish ?? "stop",
|
||||
cost: 0,
|
||||
tokens: input.tokens ?? tokens(),
|
||||
})
|
||||
|
||||
export const childCreated = (sessionID: string, parentID: string, title: string) =>
|
||||
durableEvent("session.created", {
|
||||
sessionID,
|
||||
slug: sessionID,
|
||||
projectID: "project",
|
||||
location: { directory: "/workspace" },
|
||||
parentID,
|
||||
title,
|
||||
version: "test",
|
||||
})
|
||||
|
||||
export function toolStarted(sessionID: string, id: string, name: string) {
|
||||
return durableEvent("session.tool.input.started", { sessionID, assistantMessageID: "msg_tools", id, name })
|
||||
}
|
||||
|
||||
export function toolCalled(sessionID: string, id: string, input: EventData<"session.tool.called">["input"]) {
|
||||
return durableEvent("session.tool.called", { sessionID, assistantMessageID: "msg_tools", id, input, executed: false })
|
||||
}
|
||||
|
||||
export function toolProgress(sessionID: string, id: string, metadata: EventData<"session.tool.progress">["metadata"]) {
|
||||
return ephemeralEvent("session.tool.progress", { sessionID, assistantMessageID: "msg_tools", id, metadata })
|
||||
}
|
||||
|
||||
export function toolSucceeded(
|
||||
sessionID: string,
|
||||
id: string,
|
||||
metadata: EventData<"session.tool.success">["metadata"],
|
||||
text: string,
|
||||
) {
|
||||
return durableEventV2("session.tool.success", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_tools",
|
||||
id,
|
||||
metadata,
|
||||
content: [{ type: "text", text }],
|
||||
executed: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function toolFailed(
|
||||
sessionID: string,
|
||||
id: string,
|
||||
input: Omit<EventData<"session.tool.failed">, "sessionID" | "assistantMessageID" | "id" | "executed">,
|
||||
) {
|
||||
return durableEventV2("session.tool.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_tools",
|
||||
id,
|
||||
executed: true,
|
||||
...input,
|
||||
})
|
||||
}
|
||||
|
||||
export function permissionAsked(
|
||||
sessionID: string,
|
||||
id: string,
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly metadata?: EventData<"permission.asked">["metadata"]
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
} = {},
|
||||
) {
|
||||
return ephemeralEvent("permission.asked", {
|
||||
id,
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
resources: ["*"],
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function startWire(options: WireOptions = {}) {
|
||||
const waiters = new Set<() => void>()
|
||||
const changed = () => waiters.forEach((check) => check())
|
||||
const server = startServer(options, changed)
|
||||
|
||||
const received: AnyMessage[] = []
|
||||
const updates: SessionNotification[] = []
|
||||
const permissions: RequestPermissionRequest[] = []
|
||||
const writes: WriteTextFileRequest[] = []
|
||||
const childUpdates: ChildUpdate[] = []
|
||||
// Client handlers record SDK-validated params; responses wait until they have seen every earlier agent message.
|
||||
const counts = { sent: 0, handled: 0 }
|
||||
const handled = <Value>(list: Value[], value: Value) => {
|
||||
list.push(value)
|
||||
counts.handled++
|
||||
changed()
|
||||
}
|
||||
|
||||
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const agentConnection = ACP.connect(
|
||||
OpenCode.make({ baseUrl: server.url }),
|
||||
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
||||
)
|
||||
const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable)
|
||||
const connection = client({ name: "test" })
|
||||
.onNotification("session/update", (ctx) => handled(updates, ctx.params))
|
||||
.onNotification("opencode/session/child_update", Schema.decodeUnknownSync(ChildUpdate), (ctx) =>
|
||||
handled(childUpdates, ctx.params),
|
||||
)
|
||||
.onRequest("session/request_permission", (ctx) => {
|
||||
handled(permissions, ctx.params)
|
||||
return options.permission?.(ctx.params, ctx.signal) ?? { outcome: { outcome: "cancelled" } }
|
||||
})
|
||||
.onRequest("fs/write_text_file", (ctx) => {
|
||||
handled(writes, ctx.params)
|
||||
return {}
|
||||
})
|
||||
.connect({
|
||||
writable: clientStream.writable,
|
||||
readable: clientStream.readable.pipeThrough(
|
||||
new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
received.push(message)
|
||||
if ("method" in message && !message.method.startsWith("$/")) counts.sent++
|
||||
controller.enqueue(message)
|
||||
changed()
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const until = <T>(read: () => T | undefined | false, description = "condition", timeout = 5_000) => {
|
||||
const initial = read()
|
||||
if (initial !== undefined && initial !== false) return Promise.resolve(initial)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const check = () => {
|
||||
const value = read()
|
||||
if (value === undefined || value === false) return
|
||||
cleanup()
|
||||
resolve(value)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`timed out waiting for ${description}`))
|
||||
}, timeout)
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
waiters.delete(check)
|
||||
}
|
||||
waiters.add(check)
|
||||
})
|
||||
}
|
||||
|
||||
const request = <Method extends AgentRequestMethod>(
|
||||
method: Method,
|
||||
params: AgentRequestParamsByMethod[Method],
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentRequestResponsesByMethod[Method]> =>
|
||||
connection.agent.request(method, params, signal ? { cancellationSignal: signal } : undefined).finally(() => {
|
||||
const target = counts.sent
|
||||
return until(() => counts.handled >= target, "client handlers for every agent message (is one missing?)")
|
||||
})
|
||||
|
||||
const initialize = (capabilities: InitializeOptions = {}) =>
|
||||
request("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
...(capabilities.writeTextFile ? { fs: { writeTextFile: true, readTextFile: false } } : {}),
|
||||
_meta: {
|
||||
...(capabilities.childSessionUpdates ? { "opencode/child-session-updates": true } : {}),
|
||||
...(capabilities.terminalAuth ? { "terminal-auth": true } : {}),
|
||||
},
|
||||
},
|
||||
clientInfo: { name: "test", version: "1" },
|
||||
})
|
||||
|
||||
return {
|
||||
server,
|
||||
received,
|
||||
updates,
|
||||
permissions,
|
||||
writes,
|
||||
childUpdates,
|
||||
request,
|
||||
until,
|
||||
initialize,
|
||||
notify: <Method extends AgentNotificationMethod>(method: Method, params: AgentNotificationParamsByMethod[Method]) =>
|
||||
connection.agent.notify(method, params),
|
||||
newSession: (cwd = "/workspace", mcpServers: McpServer[] = []) => request("session/new", { cwd, mcpServers }),
|
||||
prompt: (sessionId: string, prompt: string | ContentBlock[], signal?: AbortSignal) =>
|
||||
request(
|
||||
"session/prompt",
|
||||
{ sessionId, prompt: typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt },
|
||||
signal,
|
||||
),
|
||||
waitForUpdate: (predicate: (update: SessionNotification) => boolean, description = "session/update") =>
|
||||
until(() => updates.find(predicate), description),
|
||||
async [Symbol.asyncDispose]() {
|
||||
connection.close()
|
||||
agentConnection.close()
|
||||
await server.stop()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type Wire = Awaited<ReturnType<typeof startWire>>
|
||||
|
||||
export async function startSession(options: WireOptions & { readonly capabilities?: InitializeOptions } = {}) {
|
||||
const wire = await startWire(options)
|
||||
await wire.initialize(options.capabilities)
|
||||
const session = await wire.newSession()
|
||||
return Object.assign(wire, { sessionId: session.sessionId, session })
|
||||
}
|
||||
|
||||
export async function rpcError(promise: Promise<unknown>) {
|
||||
const error = await promise.then(
|
||||
(result) => {
|
||||
throw new Error(`expected an ACP error, got ${JSON.stringify(result)}`)
|
||||
},
|
||||
(error: unknown) => error,
|
||||
)
|
||||
if (!(error instanceof RequestError)) throw error
|
||||
return { code: error.code, message: error.message, data: error.data }
|
||||
}
|
||||
|
||||
function startServer(options: WireOptions, changed: () => void) {
|
||||
const encoder = new TextEncoder()
|
||||
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const counter = { sessions: 0, events: 0 }
|
||||
const catalog: Catalog = {
|
||||
models: [testModel, secondModel],
|
||||
agents: [buildAgent, planAgent],
|
||||
commands: [reviewCommand],
|
||||
}
|
||||
const catalogReads: Array<{ readonly kind: CatalogKind; readonly directory: string }> = []
|
||||
const requests: ServerRequest[] = []
|
||||
const submissions: Submission[] = []
|
||||
const selections: Selection[] = []
|
||||
const interrupts: string[] = []
|
||||
const replies: Array<{ readonly sessionID: string; readonly requestID: string; readonly decision: string }> = []
|
||||
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string }> = []
|
||||
const mcp: Array<{ readonly name: string; readonly directory?: string; readonly config: unknown }> = []
|
||||
const fake = {
|
||||
requests,
|
||||
catalog,
|
||||
catalogReads,
|
||||
sessions: new Map<string, SessionInfo>(),
|
||||
messages: new Map<string, SessionMessageInfo[]>(),
|
||||
submissions,
|
||||
get prompts() {
|
||||
return submissions.filter((item): item is PromptSubmission => item.kind === "prompt")
|
||||
},
|
||||
selections,
|
||||
interrupts,
|
||||
replies,
|
||||
cancelledForms,
|
||||
mcp,
|
||||
send(...events: ReadonlyArray<OpenCodeEvent>) {
|
||||
events.forEach((event) => {
|
||||
const seq = ++counter.events
|
||||
const stamped = {
|
||||
...event,
|
||||
id: `evt_${seq}`,
|
||||
created: seq,
|
||||
...("durable" in event ? { durable: { ...event.durable, seq } } : {}),
|
||||
}
|
||||
const chunk = encoder.encode(`data: ${JSON.stringify(stamped)}\n\n`)
|
||||
streams.forEach((stream) => stream.enqueue(chunk))
|
||||
})
|
||||
},
|
||||
}
|
||||
const emit = async (events: Events | Promise<Events>) => {
|
||||
const resolved = await events
|
||||
if (resolved) fake.send(...resolved)
|
||||
return resolved?.length ?? 0
|
||||
}
|
||||
const createSession = (source: SessionInfo) => {
|
||||
const session = { ...source, id: `ses_${++counter.sessions}` }
|
||||
fake.sessions.set(session.id, session)
|
||||
return session
|
||||
}
|
||||
const notFound = (sessionID: string) =>
|
||||
Response.json({ _tag: "SessionNotFoundError", sessionID, message: "session not found" }, { status: 404 })
|
||||
const noContent = () => new Response(null, { status: 204 })
|
||||
|
||||
// Handlers record facts synchronously before awaiting hooks, so waiters can observe a held request.
|
||||
const observed = (response: Response | Promise<Response>) => {
|
||||
changed()
|
||||
return response
|
||||
}
|
||||
const record = async (req: Request) => {
|
||||
const url = new URL(req.url)
|
||||
const text = req.method === "GET" ? "" : await req.text()
|
||||
const request: ServerRequest = {
|
||||
method: req.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
body: text ? Option.getOrUndefined(decodeJson(text)) : undefined,
|
||||
}
|
||||
fake.requests.push(request)
|
||||
changed()
|
||||
return { request, text, override: await options.fetch?.(request) }
|
||||
}
|
||||
function route<Path extends string>(
|
||||
handle: (req: BunRequest<Path>, query: Record<string, string>) => Response | Promise<Response>,
|
||||
) {
|
||||
return (req: BunRequest<Path>) =>
|
||||
record(req)
|
||||
.then((recorded) => recorded.override ?? observed(handle(req, recorded.request.query)))
|
||||
.finally(changed)
|
||||
}
|
||||
function body<Path extends string, A>(
|
||||
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))
|
||||
return (req: BunRequest<Path>) =>
|
||||
record(req)
|
||||
.then((recorded) => {
|
||||
if (recorded.override) return recorded.override
|
||||
const parsed = decode(recorded.text)
|
||||
if (Option.isNone(parsed)) return new Response(null, { status: 400 })
|
||||
return observed(handle(req, parsed.value, recorded.request.query))
|
||||
})
|
||||
.finally(changed)
|
||||
}
|
||||
const catalogRoute = (kind: CatalogKind) =>
|
||||
route((_req, query) => {
|
||||
const directory = query["location[directory]"] ?? "/workspace"
|
||||
fake.catalogReads.push({ kind, directory })
|
||||
const data = {
|
||||
model: catalog.models,
|
||||
default: catalog.defaultModel === undefined ? (catalog.models[0] ?? null) : catalog.defaultModel,
|
||||
agent: catalog.agents,
|
||||
command: catalog.commands,
|
||||
}[kind]
|
||||
return Response.json({ location: { directory, project: { id: "global", directory } }, data })
|
||||
})
|
||||
const page = <Item>(items: readonly Item[], query: Record<string, string>, limit: number) => {
|
||||
const start = Number(query.cursor ?? 0)
|
||||
const end = start + Number(query.limit ?? limit)
|
||||
return { data: items.slice(start, end), cursor: end < items.length ? { next: String(end) } : {} }
|
||||
}
|
||||
|
||||
const http = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (req) =>
|
||||
record(req)
|
||||
.then((recorded) => recorded.override ?? new Response(null, { status: 404 }))
|
||||
.finally(changed),
|
||||
routes: {
|
||||
"/api/event": {
|
||||
GET: route(() => {
|
||||
const state: { stream?: ReadableStreamDefaultController<Uint8Array> } = {}
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(stream) {
|
||||
state.stream = stream
|
||||
streams.add(stream)
|
||||
stream.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
|
||||
),
|
||||
)
|
||||
},
|
||||
cancel() {
|
||||
if (state.stream) streams.delete(state.stream)
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
},
|
||||
"/api/model": { GET: catalogRoute("model") },
|
||||
"/api/model/default": { GET: catalogRoute("default") },
|
||||
"/api/agent": { GET: catalogRoute("agent") },
|
||||
"/api/command": { GET: catalogRoute("command") },
|
||||
"/api/session": {
|
||||
GET: route((_req, query) => {
|
||||
const sessions = [...fake.sessions.values()]
|
||||
.filter((session) => !query.directory || session.location.directory === query.directory)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
return Response.json(page(sessions, query, 100))
|
||||
}),
|
||||
POST: body(CreateBody, (_req, input) =>
|
||||
Response.json({ data: createSession(makeSession("", { cwd: input.location.directory })) }),
|
||||
),
|
||||
},
|
||||
"/api/session/:sessionID": {
|
||||
GET: route((req) => {
|
||||
const session = fake.sessions.get(req.params.sessionID)
|
||||
return session ? Response.json({ data: session }) : notFound(req.params.sessionID)
|
||||
}),
|
||||
DELETE: route((req) =>
|
||||
fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID),
|
||||
),
|
||||
},
|
||||
"/api/session/:sessionID/fork": {
|
||||
POST: route((req) => {
|
||||
const source = fake.sessions.get(req.params.sessionID)
|
||||
if (!source) return notFound(req.params.sessionID)
|
||||
const forked = createSession(source)
|
||||
fake.messages.set(forked.id, [...(fake.messages.get(source.id) ?? [])])
|
||||
return Response.json({ data: forked })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/model": {
|
||||
POST: body(ModelBody, (req, input) => {
|
||||
fake.selections.push({ sessionID: req.params.sessionID, model: input.model })
|
||||
return noContent()
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/agent": {
|
||||
POST: body(AgentBody, (req, input) => {
|
||||
fake.selections.push({ sessionID: req.params.sessionID, agent: input.agent })
|
||||
return noContent()
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/message": {
|
||||
GET: route((req, query) => Response.json(page(fake.messages.get(req.params.sessionID) ?? [], query, 200))),
|
||||
},
|
||||
"/api/session/:sessionID/message/:messageID": {
|
||||
GET: route((req) => {
|
||||
const message = fake.messages.get(req.params.sessionID)?.find((item) => item.id === req.params.messageID)
|
||||
return message ? Response.json({ data: message }) : new Response(null, { status: 404 })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/prompt": {
|
||||
POST: body(PromptBody, async (req, input) => {
|
||||
const sessionID = req.params.sessionID
|
||||
fake.submissions.push({ kind: "prompt", sessionID, ...input })
|
||||
const hook = options.onPrompt ?? (() => turn(sessionID, input.id))
|
||||
await emit(hook({ sessionID, id: input.id, text: input.text, signal: req.signal }))
|
||||
return Response.json({ data: { text: input.text } })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/command": {
|
||||
POST: body(CommandBody, (req, input) => {
|
||||
fake.submissions.push({ kind: "command", sessionID: req.params.sessionID, ...input })
|
||||
return noContent()
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/compact": {
|
||||
POST: body(CompactBody, (req, input) => {
|
||||
fake.submissions.push({ kind: "compact", sessionID: req.params.sessionID, ...input })
|
||||
fake.send(...turn(req.params.sessionID, input.id))
|
||||
return Response.json({ data: {} })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/synthetic": {
|
||||
POST: body(SyntheticBody, (req, input) => {
|
||||
fake.submissions.push({ kind: "synthetic", sessionID: req.params.sessionID, ...input })
|
||||
return Response.json({ data: {} })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/interrupt": {
|
||||
POST: route(async (req) => {
|
||||
const sessionID = req.params.sessionID
|
||||
fake.interrupts.push(sessionID)
|
||||
if (!fake.sessions.has(sessionID)) return notFound(sessionID)
|
||||
return Response.json({ interrupted: (await emit(options.onInterrupt?.({ sessionID }))) > 0 })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/permission/:requestID/reply": {
|
||||
POST: body(ReplyBody, async (req, input) => {
|
||||
const reply = { sessionID: req.params.sessionID, requestID: req.params.requestID, decision: input.decision }
|
||||
fake.replies.push(reply)
|
||||
await emit(options.onPermissionReply?.(reply))
|
||||
return noContent()
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/form/:formID": {
|
||||
DELETE: route(async (req) => {
|
||||
const form = { sessionID: req.params.sessionID, formID: req.params.formID }
|
||||
fake.cancelledForms.push(form)
|
||||
await emit(options.onFormCancel?.(form))
|
||||
return noContent()
|
||||
}),
|
||||
},
|
||||
"/api/experimental/mcp/:name": {
|
||||
PUT: body(McpBody, (req, input, query) => {
|
||||
fake.mcp.push({ name: req.params.name, directory: query["location[directory]"], config: input.config })
|
||||
return noContent()
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return Object.assign(fake, {
|
||||
url: http.url.toString(),
|
||||
async stop() {
|
||||
streams.forEach((stream) => stream.close())
|
||||
streams.clear()
|
||||
await http.stop(true)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1414,7 +1414,11 @@ export type SessionFormReplyOperation<E = never> = (
|
||||
input: SessionFormReplyInput,
|
||||
) => Effect.Effect<SessionFormReplyOutput, E>
|
||||
|
||||
export type SessionFormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
|
||||
export type SessionFormCancelInput = {
|
||||
readonly sessionID: string
|
||||
readonly formID: Form.ID
|
||||
readonly message?: string | undefined
|
||||
}
|
||||
export type SessionFormCancelOutput = void
|
||||
export type SessionFormCancelOperation<E = never> = (
|
||||
input: SessionFormCancelInput,
|
||||
|
||||
@@ -735,9 +735,10 @@ const EndpointSessionFormReply = (raw: RawClient["server.session"]) => (input: S
|
||||
|
||||
const EndpointSessionFormCancel = (raw: RawClient["server.session"]) => (input: SessionFormCancelInput) =>
|
||||
preserveEffect<SessionFormCancelOutput>()(
|
||||
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
raw["session.form.cancel"]({
|
||||
params: { sessionID: input["sessionID"], formID: input["formID"] },
|
||||
query: { message: input["message"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
|
||||
|
||||
@@ -1058,6 +1058,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
|
||||
query: { message: input["message"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404, 409],
|
||||
empty: true,
|
||||
|
||||
@@ -1897,7 +1897,10 @@ export type FormField =
|
||||
| FormMultiselectField
|
||||
| FormExternalField
|
||||
|
||||
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
|
||||
export type FormState =
|
||||
| { status: "pending" }
|
||||
| { status: "answered"; answer: FormAnswer }
|
||||
| { status: "cancelled"; message?: string }
|
||||
|
||||
export type CredentialKey = {
|
||||
type: "key"
|
||||
@@ -5460,6 +5463,7 @@ export type SessionFormReplyOutput = void
|
||||
export type SessionFormCancelInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly formID: string }["sessionID"]
|
||||
readonly formID: { readonly sessionID: string; readonly formID: string }["formID"]
|
||||
readonly message?: { readonly message?: string | undefined }["message"]
|
||||
}
|
||||
|
||||
export type SessionFormCancelOutput = void
|
||||
|
||||
@@ -74,6 +74,10 @@ export interface ReplyInput {
|
||||
readonly answer: Answer
|
||||
}
|
||||
|
||||
export interface CancelOptions {
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
export interface ListInput {
|
||||
readonly sessionID?: Form.Info["sessionID"]
|
||||
}
|
||||
@@ -86,7 +90,7 @@ export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, AlreadySettledError | InvalidAnswerError | NotFoundError>
|
||||
readonly cancel: (id: ID) => Effect.Effect<void, AlreadySettledError | NotFoundError>
|
||||
readonly cancel: (id: ID, options?: CancelOptions) => Effect.Effect<void, AlreadySettledError | NotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Form") {}
|
||||
@@ -192,12 +196,15 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const cancel = Effect.fn("Form.cancel")((id: ID) =>
|
||||
const cancel = Effect.fn("Form.cancel")((id: ID, options?: CancelOptions) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* requireEntry(id)
|
||||
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
|
||||
const next: TerminalState = { status: "cancelled" }
|
||||
const next: TerminalState = {
|
||||
status: "cancelled",
|
||||
...(options?.message === undefined ? {} : { message: options.message }),
|
||||
}
|
||||
yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID })
|
||||
yield* Cache.set(forms, id, { ...entry, state: next })
|
||||
yield* Deferred.succeed(entry.deferred, next)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -213,7 +213,6 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
const pre = [
|
||||
ToolInputRepairPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeDefaultsPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
@@ -251,6 +250,7 @@ const post = [
|
||||
ConfigInstructionPlugin.Plugin,
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigCompactionPlugin.Plugin,
|
||||
ConfigFormatterPlugin.Plugin,
|
||||
|
||||
@@ -276,6 +276,8 @@ export const layer = Layer.effect(
|
||||
model: model.model,
|
||||
http: {
|
||||
headers: {
|
||||
"x-opencode-session-id": session.id,
|
||||
...(session.parentID ? { "x-opencode-parent-session-id": session.parentID } : {}),
|
||||
"x-session-affinity": affinity,
|
||||
"X-Session-Id": affinity,
|
||||
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
|
||||
|
||||
@@ -90,6 +90,10 @@ export const Plugin = {
|
||||
// Deliberate defect tunnel (see Permission.assert): a dismissal must dodge
|
||||
// leaf `mapError` blankets so it never becomes model-facing tool output; it
|
||||
// resurfaces as a typed failure at SessionModelRequest.executeTool.
|
||||
// A dismissal with a message (e.g. from a non-interactive client) is ordinary
|
||||
// model-facing feedback: the model continues instead of the step ending.
|
||||
if (state.status === "cancelled" && state.message !== undefined)
|
||||
return Effect.fail(new ToolFailure({ message: state.message }))
|
||||
if (state.status === "cancelled") return Effect.die(new CancelledError())
|
||||
const output = {
|
||||
answers: input.questions.map((_, index): Question.Answer => {
|
||||
|
||||
@@ -88,15 +88,15 @@ export const Plugin = {
|
||||
if (result.target.externalDirectory !== undefined) return
|
||||
const resolved = yield* fs.resolve(result.target.absolute)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
// The Location and its ancestors are already supplied by initial instructions,
|
||||
// even when an upward walk from elsewhere in the project cannot reach root.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: result.content.type === "list-page" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
(file) => !FSUtil.contains(dirname(file), root),
|
||||
)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
|
||||
@@ -445,6 +445,8 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.promptCacheKey).toBe(parentID)
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-opencode-session-id": session.id,
|
||||
"x-opencode-parent-session-id": parentID,
|
||||
"x-session-affinity": parentID,
|
||||
"X-Session-Id": parentID,
|
||||
"x-parent-session-id": parentID,
|
||||
|
||||
@@ -4682,6 +4682,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("Run correlated request")
|
||||
|
||||
expect(s.requests[0]?.http?.headers).toEqual({
|
||||
"x-opencode-session-id": sessionID,
|
||||
"x-session-affinity": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
"User-Agent": App.useragent(App.make()),
|
||||
@@ -4703,6 +4704,8 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("Run child request")
|
||||
|
||||
expect(s.requests[0]?.http?.headers).toMatchObject({
|
||||
"x-opencode-session-id": sessionID,
|
||||
"x-opencode-parent-session-id": parentID,
|
||||
"x-session-affinity": parentID,
|
||||
"X-Session-Id": parentID,
|
||||
"x-parent-session-id": parentID,
|
||||
|
||||
@@ -211,6 +211,7 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-opencode-session-id": sessionID,
|
||||
"x-session-affinity": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
"User-Agent": App.useragent(App.make()),
|
||||
|
||||
@@ -19,9 +19,38 @@ type Attachment = {
|
||||
export type Connection = Effect.Success<ReturnType<typeof make>>
|
||||
|
||||
export const make = Effect.fn("BrowserConnection.make")(function* (
|
||||
ctx: Pick<Context, "rpc" | "session" | "location" | "event">,
|
||||
ctx: Pick<Context, "agent" | "rpc" | "session" | "location" | "event">,
|
||||
) {
|
||||
const browsers = new Map<Session.ID, Attachment>()
|
||||
// Appended after config, so every agent hides the browser until an attachment allows it.
|
||||
yield* ctx.agent.transform((editor) =>
|
||||
editor.list().forEach((agent) => agent.permissions.push({ action: "browser", resource: "*", effect: "deny" })),
|
||||
)
|
||||
// Each attachment adds and removes its own allow, so a replaced attachment keeps the new one's.
|
||||
const grant = (sessionID: Session.ID) =>
|
||||
Effect.acquireRelease(
|
||||
ctx.session.get({ sessionID }).pipe(
|
||||
Effect.flatMap((session) =>
|
||||
ctx.session.update({
|
||||
sessionID,
|
||||
permissions: [...(session.permissions ?? []), { action: "browser", resource: "*", effect: "allow" }],
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
),
|
||||
() =>
|
||||
ctx.session.get({ sessionID }).pipe(
|
||||
Effect.flatMap((session) => {
|
||||
const permissions = session.permissions ?? []
|
||||
const index = permissions.findIndex(
|
||||
(rule) => rule.action === "browser" && rule.resource === "*" && rule.effect === "allow",
|
||||
)
|
||||
if (index === -1) return Effect.void
|
||||
return ctx.session.update({ sessionID, permissions: permissions.toSpliced(index, 1) })
|
||||
}),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
let active = true
|
||||
const close = (sessionID: Session.ID, reason: "closed" | "replaced" = "closed") =>
|
||||
Effect.gen(function* () {
|
||||
@@ -69,6 +98,7 @@ export const make = Effect.fn("BrowserConnection.make")(function* (
|
||||
}),
|
||||
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
|
||||
)
|
||||
yield* grant(input.sessionID)
|
||||
yield* rpc.events
|
||||
.emit("control", { type: "attached", connectionID: input.connectionID, version: 4 })
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.13",
|
||||
"@opentui/solid": ">=0.5.13",
|
||||
"@opentui/core": ">=0.5.14",
|
||||
"@opentui/solid": ">=0.5.14",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -4566,7 +4566,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Cancel a pending form.",
|
||||
"description": "Cancel a pending form, optionally telling the asker why it was not answered.",
|
||||
"summary": "Cancel form"
|
||||
}
|
||||
},
|
||||
@@ -4591,6 +4591,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -14170,6 +14185,9 @@
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["cancelled"]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status"],
|
||||
|
||||
@@ -855,6 +855,7 @@ export const makeSessionGroup = <
|
||||
.add(
|
||||
HttpApiEndpoint.delete("session.form.cancel", "/api/session/:sessionID/form/:formID", {
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
query: Schema.Struct({ message: Schema.optional(Schema.String) }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, FormAlreadySettledError, FormNotFoundError],
|
||||
})
|
||||
@@ -863,7 +864,7 @@ export const makeSessionGroup = <
|
||||
OpenApi.annotations({
|
||||
identifier: "session.form.cancel",
|
||||
summary: "Cancel form",
|
||||
description: "Cancel a pending form.",
|
||||
description: "Cancel a pending form, optionally telling the asker why it was not answered.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -149,7 +149,8 @@ export type Answer = typeof Answer.Type
|
||||
export const State = Schema.Union([
|
||||
Schema.Struct({ status: Schema.Literal("pending") }),
|
||||
Schema.Struct({ status: Schema.Literal("answered"), answer: Answer }),
|
||||
Schema.Struct({ status: Schema.Literal("cancelled") }),
|
||||
// A message tells the asker why nobody answered, e.g. a non-interactive client.
|
||||
Schema.Struct({ status: Schema.Literal("cancelled"), message: Schema.String.pipe(optional) }),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("status"))
|
||||
.annotate({ identifier: "Form.State" })
|
||||
|
||||
@@ -692,7 +692,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
"session.form.cancel",
|
||||
Effect.fn(function* (ctx) {
|
||||
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
|
||||
yield* owned.form.cancel(ctx.params.formID).pipe(
|
||||
yield* owned.form.cancel(ctx.params.formID, { message: ctx.query.message }).pipe(
|
||||
Effect.catchTags({
|
||||
"Form.AlreadySettledError": (error) =>
|
||||
new FormAlreadySettledError({ id: error.id, message: error.message }),
|
||||
|
||||
@@ -260,9 +260,7 @@ function InstructionsGroup(props: GroupProps) {
|
||||
Instructions: {files()} {files() === 1 ? "file" : "files"}
|
||||
</InlineToolRow>
|
||||
<Show when={disclosure.expanded()}>
|
||||
<box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Children {...props} nodes={props.node.children} mode="normal" />
|
||||
</box>
|
||||
<Children {...props} nodes={props.node.children} mode="normal" />
|
||||
</Show>
|
||||
</GroupAnchor>
|
||||
)
|
||||
|
||||
@@ -153,6 +153,55 @@ test("retains nested expansion state and registers exact headers and parts", asy
|
||||
expect(anchors.list()).toEqual([])
|
||||
})
|
||||
|
||||
test("expanded instructions stay adjacent to their summary and each other", async () => {
|
||||
const anchors = createTimelineAnchors()
|
||||
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
|
||||
const messages: SessionMessageInfo[] = ["AGENTS.md", "packages/tui/AGENTS.md"].map((path) => ({
|
||||
type: "synthetic",
|
||||
id: path,
|
||||
text: "Instructions",
|
||||
description: `Loaded ${path}`,
|
||||
metadata: { instruction: { paths: [path] } },
|
||||
time: { created: 1 },
|
||||
}))
|
||||
const row: SessionGroup = {
|
||||
type: "group",
|
||||
kind: "instructions",
|
||||
size: messages.length,
|
||||
completed: true,
|
||||
children: messages.map((message) => ({
|
||||
type: "entry",
|
||||
size: 1,
|
||||
entry: { type: "message", messageID: message.id },
|
||||
})),
|
||||
}
|
||||
const app = await mount({
|
||||
row,
|
||||
anchors,
|
||||
config: createTuiResolvedConfig({ animations: false }),
|
||||
expanded: (id) => expanded[id],
|
||||
setExpanded: (id, value) => setExpanded(id, value),
|
||||
message: (id) => messages.find((message) => message.id === id),
|
||||
entry: (entry) => <text>{entry.type === "message" ? `Loaded ${entry.messageID}` : ""}</text>,
|
||||
})
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Instructions: 2 files"))
|
||||
expect(app.captureCharFrame()).not.toContain("Loaded AGENTS.md")
|
||||
await app.mockMouse.click(4, anchors.get({ type: "group", groupID: groupID(row, 0)! })?.node.y ?? -1)
|
||||
await app.renderOnce()
|
||||
expect(
|
||||
app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("\n"),
|
||||
).toContain("◈ Instructions: 2 files\nLoaded AGENTS.md\nLoaded packages/tui/AGENTS.md")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("a low activity group with nothing finished stays collapsed behind a status", async () => {
|
||||
const config = createTuiResolvedConfig({ animations: false })
|
||||
const shell = (id: string) => ({
|
||||
|
||||
@@ -4566,7 +4566,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Cancel a pending form.",
|
||||
"description": "Cancel a pending form, optionally telling the asker why it was not answered.",
|
||||
"summary": "Cancel form"
|
||||
}
|
||||
},
|
||||
@@ -4591,6 +4591,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -14170,6 +14185,9 @@
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["cancelled"]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status"],
|
||||
|
||||
@@ -4566,7 +4566,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Cancel a pending form.",
|
||||
"description": "Cancel a pending form, optionally telling the asker why it was not answered.",
|
||||
"summary": "Cancel form"
|
||||
}
|
||||
},
|
||||
@@ -4591,6 +4591,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -14170,6 +14185,9 @@
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["cancelled"]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status"],
|
||||
|
||||
Reference in New Issue
Block a user