Compare commits

...

26 Commits

Author SHA1 Message Date
Aiden Cline f1a95735b8 fix(plugin): resolve request hook rebase 2026-07-21 19:15:26 +00:00
Aiden Cline a84babca73 refactor(plugin): simplify session request hook 2026-07-21 19:12:19 +00:00
Aiden Cline 7edaa05869 feat(plugin): wire session request hook 2026-07-21 19:12:08 +00:00
Aiden Cline 518d64d684 feat(plugin): add session request hook 2026-07-21 19:11:08 +00:00
Simon Klee c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden Cline 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
Dax 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
opencode-agent[bot] caf727ecb7 fix(core): filter unsupported media inputs (#38145)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-21 13:44:10 -05:00
Dax 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon Klee dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden Cline 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden Cline dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
Simon Klee 99b2e78d75 fix(tui): improve responsive footer details (#38116) 2026-07-21 19:00:21 +02:00
opencode-agent[bot] 023f76f6fc fix(core): refresh model catalog every five minutes (#38142)
Co-authored-by: Victor Navarro <36263538+vimtor@users.noreply.github.com>
2026-07-21 16:34:54 +00:00
Dax 69d7c2add7 feat(util): configure package publishing (#38129) 2026-07-21 12:32:23 -04:00
Shoubhit Dash cf651bc41b feat(cli): port ACP to V2 (#37907) 2026-07-21 21:13:21 +05:30
Aiden Cline 8d80365ef4 refactor(codemode): simplify model-facing wording (#38042) 2026-07-21 10:19:53 -05:00
Aiden Cline 3ea8895299 fix(core): enforce patch EOF anchors (#38039) 2026-07-21 10:15:51 -05:00
Dax 3304e79827 fix(tui): prevent autocomplete search flicker (#38128) 2026-07-21 10:41:36 -04:00
opencode-agent[bot] cf2c1bf425 refactor(core): break plugin host cycle (#38120)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-21 10:35:00 -04:00
Dax e0810753f2 refactor: extract shared util package (#37828) 2026-07-21 10:34:29 -04:00
Dax 7243bd9e12 fix: preserve runtime option defaults (#38118) 2026-07-21 10:30:18 -04:00
Simon Klee dd50d457b0 refactor(tui): simplify mini footer status line (#38112) 2026-07-21 16:08:19 +02:00
Simon Klee 592ef7433a mini: settle on wait and durable pending (#37984) 2026-07-21 15:42:55 +02:00
opencode-agent[bot] fd97d789ef test(app): stabilize offset observer test (#38044)
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404 target:linux-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404-arm target:linux-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:macos-26 target:darwin-arm64]) (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-21 00:26:46 -05:00
Aiden Cline 7111f93836 feat(codemode): support bounded async iteration (#38040) 2026-07-21 00:02:31 -05:00
388 changed files with 10114 additions and 1851 deletions
+42 -19
View File
@@ -124,16 +124,18 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
@@ -358,52 +360,38 @@
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
"devDependencies": {
@@ -418,10 +406,7 @@
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@@ -706,6 +691,7 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@@ -771,6 +757,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"effect": "catalog:",
},
@@ -898,6 +885,7 @@
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -972,6 +960,37 @@
"solid-js": "^1.9.0",
},
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.18.3",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@npmcli/arborist": "catalog:",
"@npmcli/config": "10.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"cross-spawn": "catalog:",
"effect": "catalog:",
"glob": "13.0.5",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"xdg-basedir": "5.1.0",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@typescript/native-preview": "catalog:",
},
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.4",
@@ -1140,6 +1159,8 @@
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
@@ -2098,6 +2119,8 @@
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"],
+1 -1
View File
@@ -15,7 +15,7 @@
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
+20 -14
View File
@@ -8,6 +8,7 @@ import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import type { CallOptions } from "./request-transform"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
@@ -47,7 +48,11 @@ export interface Route<Body, Prepared = unknown> {
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: (input: RouteMappedModelInput) => Model
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
options?: CallOptions,
) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
@@ -158,11 +163,11 @@ export interface Interface {
}
export interface StreamMethod {
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
(request: LLMRequest, options?: CallOptions): Stream.Stream<LLMEvent, LLMError>
}
export interface GenerateMethod {
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
(request: LLMRequest, options?: CallOptions): Effect.Effect<LLMResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -297,7 +302,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: (input) => makeRouteModel(route, input),
prepareTransport: (body, request) =>
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
request,
@@ -305,6 +310,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transformRequest: options?.transformRequest,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
@@ -373,14 +379,14 @@ export function make<Body, Prepared, Frame, Event, State>(
// `compile` is the important boundary: it turns a common `LLMRequest` into a
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: CallOptions) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved)
const prepared = yield* route.prepareTransport(body, resolved, options)
return {
request: resolved,
@@ -403,17 +409,17 @@ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMReques
})
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: CallOptions) =>
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request)
const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
)
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: CallOptions) {
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
@@ -425,17 +431,17 @@ const generateWith = (stream: Interface["stream"]) =>
export const prepare = <Body = unknown>(request: LLMRequest) =>
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
export function stream(request: LLMRequest, options?: CallOptions): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request)
return (yield* Service).stream(request, options)
}),
) as Stream.Stream<LLMEvent, LLMError>
}
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
export function generate(request: LLMRequest, options?: CallOptions): Effect.Effect<LLMResponse, LLMError> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request)
return yield* (yield* Service).generate(request, options)
}) as Effect.Effect<LLMResponse, LLMError>
}
+1
View File
@@ -9,6 +9,7 @@ export type {
Interface as LLMClientShape,
Service as LLMClientService,
} from "./client"
export type { CallOptions, RequestData, RequestTransform, RequestValue } from "./request-transform"
export * from "./executor"
export { Auth } from "./auth"
export { AuthOptions } from "./auth-options"
@@ -0,0 +1,14 @@
import type { Effect } from "effect"
export type RequestValue = null | boolean | number | string | RequestValue[] | { [key: string]: RequestValue }
export interface RequestData {
readonly headers: Record<string, string>
readonly body: Record<string, RequestValue>
}
export type RequestTransform = (request: RequestData) => Effect.Effect<RequestData>
export interface CallOptions {
readonly transformRequest?: RequestTransform
}
+47 -9
View File
@@ -1,4 +1,4 @@
import { Effect, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
@@ -6,6 +6,7 @@ import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
import type { RequestValue } from "../request-transform"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -86,24 +87,61 @@ const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (bod
return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies")
})
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
const isRequestValue = (value: unknown): value is RequestValue => {
if (value === null || ["boolean", "number", "string"].includes(typeof value)) return true
if (Array.isArray(value)) return value.every(isRequestValue)
return ProviderShared.isRecord(value) && Object.values(value).every(isRequestValue)
}
export const isRequestBody = (value: unknown): value is Record<string, RequestValue> =>
ProviderShared.isRecord(value) && Object.values(value).every(isRequestValue)
const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)
export const decodeRequestBody = (text: string) =>
decodeJson(text).pipe(
Effect.mapError(() => ProviderShared.invalidRequest("Request hooks require a JSON object body")),
Effect.flatMap((body) =>
isRequestBody(body)
? Effect.succeed(body)
: Effect.fail(ProviderShared.invalidRequest("Request hooks require a JSON object body")),
),
)
export const jsonRequestBaseParts = <Body>(input: JsonRequestInput<Body>) =>
Effect.gen(function* () {
const url = applyQuery(
renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
input.request.http?.query,
)
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
return {
url,
jsonBody: body.jsonBody,
bodyText: body.bodyText,
headers: {
...input.headers?.({ request: input.request }),
...input.request.http?.headers,
},
}
})
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
Effect.gen(function* () {
const base = yield* jsonRequestBaseParts(input)
const transformRequest = input.transformRequest
const transformed = transformRequest
? yield* transformRequest({ headers: base.headers, body: yield* decodeRequestBody(base.bodyText) })
: { headers: base.headers, body: base.jsonBody }
const bodyText = transformRequest ? ProviderShared.encodeJson(transformed.body) : base.bodyText
const headers = yield* Auth.toEffect(input.auth)({
request: input.request,
method: "POST",
url,
body: body.bodyText,
headers: Headers.fromInput({
...input.headers?.({ request: input.request }),
...input.request.http?.headers,
}),
url: base.url,
body: bodyText,
headers: Headers.fromInput(transformed.headers),
})
return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers }
return { url: base.url, jsonBody: transformed.body, bodyText, headers }
})
export interface HttpJsonInput<_Body, Frame> {
+2
View File
@@ -4,6 +4,7 @@ import { Auth } from "../auth"
import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema"
import type { RequestTransform } from "../request-transform"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
@@ -27,6 +28,7 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transformRequest?: RequestTransform
}
export * as HttpTransport from "./http"
+17 -4
View File
@@ -1,6 +1,8 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { encodeJson } from "../../protocols/shared"
import { LLMError, TransportReason } from "../../schema"
import { Auth } from "../auth"
import * as HttpTransport from "./http"
import type { Transport } from "./index"
@@ -228,13 +230,24 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
with: (patch) => json({ ...input, ...patch }),
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts({
...prepareInput,
const parts = yield* HttpTransport.jsonRequestBaseParts(prepareInput)
const message = input.encodeMessage(yield* input.toMessage(parts.jsonBody))
const transformRequest = prepareInput.transformRequest
const transformed = transformRequest
? yield* transformRequest({ headers: parts.headers, body: yield* HttpTransport.decodeRequestBody(message) })
: undefined
const bodyText = transformed ? encodeJson(transformed.body) : message
const headers = yield* Auth.toEffect(prepareInput.auth)({
request: prepareInput.request,
method: "POST",
url: parts.url,
body: bodyText,
headers: Headers.fromInput(transformed?.headers ?? parts.headers),
})
return {
url: yield* webSocketUrl(parts.url),
headers: parts.headers,
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
headers,
message: bodyText,
}
}),
frames: (prepared, _request, runtime) => {
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
@@ -136,6 +136,41 @@ describe("request option precedence", () => {
),
)
it.effect("transforms provider-native headers and JSON", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
yield* LLMClient.stream(LLM.request({ model, prompt: "Say hello." }), {
transformRequest: (request) =>
Effect.sync(() => {
const body = { ...request.body, store: true }
delete body.stream_options
return {
headers: { ...request.headers, "x-plugin": "enabled" },
body,
}
}),
}).pipe(
Stream.runDrain,
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("authorization")).toBe("Bearer test")
expect(web.headers.get("x-plugin")).toBe("enabled")
expect(decodeJson(input.text)).toMatchObject({ store: true })
expect(decodeJson(input.text)).not.toHaveProperty("stream_options")
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
}),
)
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
@@ -251,16 +251,34 @@ describe("OpenAI Responses route", () => {
}),
),
)
const response = yield* LLMClient.generate(
const text: string[] = []
yield* LLMClient.stream(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
"gpt-4.1-mini",
),
prompt: "Say hello.",
}),
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
{
transformRequest: (request) =>
Effect.sync(() => {
expect(request.body.type).toBe("response.create")
expect(request.body.stream).toBeUndefined()
const body = { ...request.body, plugin: true }
delete body.store
return { headers: { ...request.headers, "x-plugin": "enabled" }, body }
}),
},
).pipe(
Stream.runForEach((event) =>
Effect.sync(() => {
if (LLMEvent.is.textDelta(event)) text.push(event.text)
}),
),
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
)
expect(response.text).toBe("Hi")
expect(text.join("")).toBe("Hi")
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
expect(closed).toBe(true)
expect(sent).toHaveLength(1)
@@ -268,7 +286,7 @@ describe("OpenAI Responses route", () => {
type: "response.create",
model: "gpt-4.1-mini",
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
store: false,
plugin: true,
})
}),
)
@@ -38,17 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
+3 -1
View File
@@ -23,16 +23,18 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
+1 -2
View File
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto"
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
@@ -40,7 +39,7 @@ export async function collectNodeAssets(target: NodeTarget) {
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{
key: photonWasmAsset,
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...attentionSoundAssets.map((key) => ({
key,
+65
View File
@@ -0,0 +1,65 @@
import {
RequestError,
type Agent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
import { ACPError } from "./error"
import { ACPService } from "./service"
export function create(client: OpenCodeClient, connection: AgentSideConnection) {
const service = ACPService.make({ client, connection })
return {
initialize: (params: InitializeRequest) => run(service.initialize(params)),
authenticate: (params: AuthenticateRequest) => run(service.authenticate(params)),
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
prompt: (params: PromptRequest) => run(service.prompt(params)),
cancel: (params: CancelNotification) => run(service.cancel(params)),
} satisfies Agent
}
async function run<A>(promise: Promise<A>) {
try {
return await promise
} catch (error) {
if (error instanceof RequestError) throw error
if (isACPError(error)) throw ACPError.toRequestError(error)
throw ACPError.toRequestError(ACPError.fromUnknown(error))
}
}
function isACPError(error: unknown): error is ACPError.Error {
return (
error instanceof ACPError.SessionNotFoundError ||
error instanceof ACPError.InvalidConfigOptionError ||
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.AuthRequiredError ||
error instanceof ACPError.UnknownAuthMethodError ||
error instanceof ACPError.ServiceFailureError
)
}
export * as ACP from "./agent"
+133
View File
@@ -0,0 +1,133 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
export const DEFAULT_VARIANT_VALUE = "default"
export type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
}
export type ConfigOptionProvider = {
id: string
name: string
models: ReadonlyArray<ConfigOptionModel>
}
export type ConfigOptionMode = {
id: string
name: string
description?: string
}
export type ModelSelection = {
model: { providerID: string; modelID: string }
variant?: string
}
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants =
input.providers
.find((provider) => provider.id === input.currentModel.providerID)
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
const effort =
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
return [
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}
export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
options: input.providers.flatMap((provider) =>
provider.models
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
),
}
}
export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption {
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
}
}
export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (!provider) {
const separator = modelId.indexOf("/")
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
}
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
const separator = modelID.lastIndexOf("/")
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
const model = provider.models.find((item) => item.id === baseModelID)
if (model && variant && model.variants?.includes(variant)) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
return { model: { providerID: provider.id, modelID } }
}
export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
}
export * as ACPConfigOption from "./config-option"
+183
View File
@@ -0,0 +1,183 @@
import type { ContentBlock, ContentChunk, ResourceLink } from "@agentclientprotocol/sdk"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
export type PromptPart =
| { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean }
| { readonly type: "file"; readonly url: string; readonly filename?: string; readonly mime: string }
export type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
return content.flatMap(contentBlockToParts)
}
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text": {
const audience = block.annotations?.audience
if (audience?.length === 1 && audience[0] === "assistant") {
return [{ type: "text", text: block.text, synthetic: true }]
}
if (audience?.length === 1 && audience[0] === "user") {
return [{ type: "text", text: block.text, ignored: true }]
}
return [{ type: "text", text: block.text }]
}
case "image":
if (block.data) {
return [
{
type: "file",
url: `data:${block.mimeType};base64,${block.data}`,
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
return []
case "resource_link":
return [resourceLinkToPart(block)]
case "resource":
if ("text" in block.resource) {
try {
const parsed = new URL(block.resource.uri)
if (parsed.protocol === "file:") {
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
const decoded = (() => {
try {
return fileURLToPath(parsed)
} catch {
return decodeURIComponent(parsed.pathname)
}
})()
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
}
} catch {}
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
}
if (!block.resource.mimeType) return []
return [
{
type: "file",
url: block.resource.uri.startsWith("data:")
? block.resource.uri
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
filename: filenameFromUri(block.resource.uri) ?? "file",
mime: block.resource.mimeType,
},
]
default:
return []
}
}
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
return parts.flatMap((part): ContentChunk[] => {
if (part.type === "text") {
if (!part.text) return []
return [
{
content: {
type: "text",
text: part.text,
...(part.synthetic ? { annotations: { audience: ["assistant" as const] } } : {}),
...(!part.synthetic && part.ignored ? { annotations: { audience: ["user" as const] } } : {}),
},
},
]
}
if (part.type === "reasoning") {
return part.text ? [{ content: { type: "text", text: part.text } }] : []
}
if (part.url.startsWith("file://")) {
return [
{
content: {
type: "resource_link",
uri: part.url,
name: part.filename ?? "file",
mimeType: part.mime,
},
},
]
}
if (!part.url.startsWith("data:")) return []
const match = /^data:([^;]+);base64,(.*)$/.exec(part.url)
if (!match?.[1] || match[2] === undefined) return []
const mime = match[1]
const data = match[2]
if (mime.startsWith("image/")) {
return [
{
content: {
type: "image",
mimeType: mime,
data,
uri: pathToFileURL(part.filename ?? "image").href,
},
},
]
}
return [
{
content: {
type: "resource",
resource:
mime.startsWith("text/") || mime === "application/json"
? {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: mime,
text: Buffer.from(data, "base64").toString("utf8"),
}
: {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: mime,
blob: data,
},
},
},
]
})
}
function resourceLinkToPart(link: ResourceLink): PromptPart {
if (link.uri.startsWith("file://")) {
return {
type: "file",
url: link.uri,
filename: link.name || filenameFromUri(link.uri) || "file",
mime: link.mimeType ?? "text/plain",
}
}
if (link.uri.startsWith("zed://") && URL.canParse(link.uri)) {
const pathname = new URL(link.uri).searchParams.get("path")
if (pathname)
return {
type: "file",
url: pathToFileURL(pathname).href,
filename: link.name || path.basename(pathname) || "file",
mime: link.mimeType ?? "text/plain",
}
}
return { type: "text", text: link.uri }
}
function filenameFromUri(uri: string | undefined): string | undefined {
if (!uri || uri.startsWith("data:")) return undefined
if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
return path.basename(uri) || undefined
}
export * as ACPContent from "./content"
+86
View File
@@ -0,0 +1,86 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
}) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{ configId: Schema.String },
) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPUnknownAuthMethodError",
{ methodId: Schema.String },
) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
| SessionNotFoundError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| AuthRequiredError
| UnknownAuthMethodError
| ServiceFailureError
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
case "ACPInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPInvalidModelError":
return RequestError.invalidParams(
{ providerId: error.providerId, modelId: error.modelId },
`model not found: ${error.modelId}`,
)
case "ACPInvalidEffortError":
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
case "ACPInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPAuthRequiredError":
return RequestError.authRequired({}, "provider authentication required")
case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPServiceFailureError":
return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
}
const exhaustive: never = error
return exhaustive
}
export function fromUnknown(error: unknown, service?: string) {
const errorName = error instanceof Error ? error.name : undefined
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
}
export * as ACPError from "./error"
+441
View File
@@ -0,0 +1,441 @@
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistant,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import { replyPermission, syncEditedFiles } from "./permission"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
type ToolContent,
type ToolInput,
} from "./tool"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
export type TurnControl = {
cancelled: boolean
readonly admission: AbortController
}
type ToolState = {
readonly name: string
input: ToolInput
structured: Record<string, unknown>
content: ToolContent
}
export type TurnStart =
| { readonly type: "input"; readonly id: string }
| { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string }
function emptyToolState(): ToolState {
return { name: "tool", input: {}, structured: {}, content: [] }
}
export async function streamTurn(input: {
readonly client: OpenCodeClient
readonly connection: Connection
readonly sessionID: string
readonly cwd: string
readonly start: TurnStart
readonly userMessageID?: string | null
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
}): Promise<PromptResponse> {
const streamController = new AbortController()
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission")
const control = input.control
let started = false
let assistantMessageID: string | undefined
let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>()
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
const consume = async () => {
while (!streamController.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.v2.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
event,
sessionID: input.sessionID,
cwd: input.cwd,
tool,
})
continue
}
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
await input.client.form
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
if (matchesStart(event, input.start)) {
started = true
continue
}
if (!started) continue
if (event.type === "session.step.started") {
assistantMessageID = event.data.assistantMessageID
continue
}
if (event.type === "session.text.delta") {
assistantMessageID = event.data.assistantMessageID
await update({
sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
}
if (event.type === "session.reasoning.delta") {
assistantMessageID = event.data.assistantMessageID
await update({
sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.callID,
toolName: event.data.name,
state: { input: {} },
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.callID) ?? emptyToolState()
current.input = event.data.input
tools.set(event.data.callID, current)
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.callID)
if (!current) continue
current.structured = event.data.structured
current.content = event.data.content
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
content: current.content,
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await syncEditedFiles({
connection: input.connection,
sessionID: input.sessionID,
cwd: input.cwd,
toolName: current.name,
toolInput: current.input,
structured: event.data.structured,
}).catch(() => {})
await update({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.structured,
content: event.data.content,
result: event.data.result,
}),
})
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await update({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: current.structured,
content: current.content,
error: event.data.error.message,
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.step.ended") {
assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
continue
}
if (event.type === "session.execution.succeeded") return "succeeded" as const
if (event.type === "session.execution.interrupted") return "interrupted" as const
if (event.type === "session.execution.failed") {
executionError = event.data.error
return "failed" as const
}
}
return "interrupted" as const
}
const completed = consume()
try {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
})
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
}
}
const terminal = await completed
const assistant = assistantMessageID
? await input.client.session
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
.catch(() => undefined)
: undefined
return response(
assistant?.type === "assistant" ? assistant : undefined,
executionError,
terminal,
control.cancelled,
finish,
input.userMessageID,
)
} catch (error) {
streamController.abort()
await completed.catch(() => {})
throw error
} finally {
streamController.abort()
await stream.return?.(undefined).catch(() => {})
}
}
export async function replayMessages(
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
messages: readonly SessionMessageInfo[],
) {
for (const message of messages) await replayMessage(connection, sessionID, cwd, message).catch(() => {})
}
async function replayMessage(
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
message: SessionMessageInfo,
) {
if (message.type === "user") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "user_message_chunk",
messageId: message.id,
content: { type: "text", text: message.text },
},
})
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
type: "file",
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
filename: file.name,
mime: file.mime,
}))
for (const chunk of partsToContentChunks(files)) {
await connection.sessionUpdate({
sessionId: sessionID,
update: { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk },
})
}
return
}
if (message.type !== "assistant") return
for (const part of message.content) {
if (part.type === "text") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "agent_message_chunk",
messageId: message.id,
content: { type: "text", text: part.text },
},
})
continue
}
if (part.type === "reasoning") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: message.id,
content: { type: "text", text: part.text },
},
})
continue
}
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.status === "streaming" ? {} : part.state.input },
cwd,
}),
},
})
switch (part.state.status) {
case "completed":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
content: part.state.content,
result: part.state.result,
}),
},
})
break
case "running":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.input },
content: part.state.content,
cwd,
}),
},
})
break
case "error":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
content: part.state.content,
error: part.state.error.message,
cwd,
}),
},
})
break
case "streaming":
break
}
}
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
if (start.type === "compaction")
return event.type === "session.compaction.admitted" && event.data.inputID === start.id
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
}
function response(
assistant: SessionMessageAssistant | undefined,
executionError: { readonly type: string; readonly message: string } | undefined,
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
messageID: string | null | undefined,
): PromptResponse {
const error = assistant?.error ?? executionError
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
throw new ACPError.ServiceFailureError({
safeMessage: error.message || "OpenCode prompt failed",
service: "session",
errorName: error.type,
})
}
const tokens = assistant?.tokens
const usage = tokens
? {
inputTokens: tokens.input,
outputTokens: tokens.output,
totalTokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write,
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
}
: undefined
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
}
function resolveStopReason(input: {
readonly terminal: "succeeded" | "failed" | "interrupted"
readonly cancelled: boolean
readonly finish: SessionMessageAssistant["finish"]
readonly error?: string
}): PromptResponse["stopReason"] {
if (input.cancelled || input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
if (input.finish === "length") return "max_tokens"
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
return "end_turn"
}
export * as ACPEvent from "./event"
+179
View File
@@ -0,0 +1,179 @@
import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { Patch } from "@opencode-ai/util/patch"
import { Result } from "effect"
import { isAbsolute, resolve } from "node:path"
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.v2.asked" }>
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
type Tool = { readonly name: string; readonly input: ToolInput }
const options: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
export async function replyPermission(input: {
readonly client: OpenCodeClient
readonly connection: Connection
readonly event: PermissionEvent
readonly sessionID: string
readonly cwd: string
readonly tool?: Tool
}) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const result = await input.connection
.requestPermission({
sessionId: input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
})
.catch(() => undefined)
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
const reply = selected === "once" || selected === "always" ? selected : "reject"
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
reply,
})
}
export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly structured: Readonly<Record<string, unknown>>
}) {
if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.structured.files)
? input.structured.files.flatMap((file): string[] => {
if (!file || typeof file !== "object") return []
const path = Reflect.get(file, "file")
return typeof path === "string" ? [path] : []
})
: []
const path = filePath(input.toolInput)
const paths = [...new Set([...files, ...(path ? [path] : [])])]
await Promise.all(
paths.map(async (path) => {
const target = resolvePath(path, input.cwd)
const file = Bun.file(target)
if (!(await file.exists())) return
await input.connection.writeTextFile?.({ sessionId: input.sessionID, path: target, content: await file.text() })
}),
)
}
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
const path = filePath(input)
if (!path) return []
const oldText = await readText(path, cwd)
if (tool === "write") {
const content = stringValue(input.content)
return content === undefined ? [] : [{ type: "diff", path, oldText, newText: content }]
}
if (tool !== "edit") return []
const oldString = stringValue(input.oldString)
const newString = stringValue(input.newString)
if (oldString === undefined || newString === undefined) return []
const newText =
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
return [{ type: "diff", path, oldText, newText }]
}
async function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const patchText = stringValue(input.patchText)
if (!patchText) return []
try {
const parsed = Patch.parse(patchText)
if (Result.isFailure(parsed)) return []
return await Promise.all(
parsed.success.map(async (hunk): Promise<ToolCallContent> => {
const oldText = hunk.type === "add" ? "" : await readText(hunk.path, cwd)
if (hunk.type === "add") {
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
return { type: "diff", path: hunk.path, oldText, newText }
}
if (hunk.type === "delete") return { type: "diff", path: hunk.path, oldText, newText: "" }
return {
type: "diff",
path: hunk.movePath ?? hunk.path,
oldText,
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
}
}),
)
} catch {
return []
}
}
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
if (previews.length > 1) return `${previews.length} files`
switch (toolName.toLocaleLowerCase()) {
case "external_directory":
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
case "webfetch":
return stringValue(input.url)
case "websearch":
return stringValue(input.query)
case "grep":
case "glob":
return stringValue(input.pattern)
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
default:
return undefined
}
}
function permissionLocations(
toolName: string,
input: ToolInput,
resources: ReadonlyArray<string>,
cwd: string,
previews: ReadonlyArray<ToolCallContent>,
): ToolCallLocation[] {
const paths = previews.flatMap((preview) => (preview.type === "diff" ? [preview.path] : []))
if (paths.length > 0) return [...new Set(paths)].map((path) => ({ path }))
const locations = toLocations(toolName, input, cwd)
if (locations.length > 0) return locations
return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
}
function readText(path: string, cwd: string) {
return Bun.file(resolvePath(path, cwd))
.text()
.catch(() => "")
}
function filePath(input: ToolInput) {
return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
}
function resolvePath(path: string, cwd: string) {
return isAbsolute(path) ? path : resolve(cwd, path)
}
export * as ACPPermission from "./permission"
+531
View File
@@ -0,0 +1,531 @@
import {
isSessionNotFoundError,
type CommandInfo,
type ModelInfo,
type ModelRef,
type OpenCodeClient,
type SessionInfo,
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import type {
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
AuthMethod,
CancelNotification,
CloseSessionRequest,
CloseSessionResponse,
ForkSessionRequest,
ForkSessionResponse,
InitializeRequest,
InitializeResponse,
ListSessionsRequest,
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
McpServer,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ResumeSessionRequest,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content"
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
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[]
readonly skills: SkillInfo[]
}
type Attached = {
readonly id: string
readonly cwd: string
catalog: Catalog
model: ModelRef
modeID: string
}
type PreparedPrompt = {
readonly start: TurnStart
readonly text: string
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: CommandInfo
readonly skill?: SkillInfo
}
export interface Interface {
initialize(input: InitializeRequest): Promise<InitializeResponse>
authenticate(input: AuthenticateRequest): Promise<AuthenticateResponse>
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
setSessionModel(input: SetSessionModelRequest): Promise<SetSessionModelResponse>
prompt(input: PromptRequest): Promise<PromptResponse>
cancel(input: CancelNotification): Promise<void>
}
export function make(input: { readonly client: OpenCodeClient; readonly connection: 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, TurnControl>()
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
})
catalogs.set(cwd, loaded)
return loaded
}
const requireSession = async (sessionID: string) => {
const current = sessions.get(sessionID)
if (current) return current
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
const state: Attached = {
id: session.id,
cwd,
catalog: currentCatalog,
model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID,
}
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,
...state.catalog.skills.filter(
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
),
].map((command) => ({ name: command.name, description: command.description ?? "" })),
},
})
return state
}
const replay = async (state: Attached) => {
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,
})
return {
initialize: async (params) => {
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
id: AuthMethodID,
}
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
authMethod._meta = {
"terminal-auth": { command: "opencode", args: ["auth", "login"], label: "OpenCode Login" },
}
}
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: InstallationVersion },
}
},
authenticate: async (params) => {
if (params.methodId !== AuthMethodID) throw new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
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,
})
const state = await attach(created, params.cwd, params.mcpServers)
return { sessionId: state.id, configOptions: configOptions(state) }
},
loadSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers)
await replay(state)
return { configOptions: configOptions(state) }
},
listSessions: async (params) => {
const page = await input.client.session.list({
...(params.cwd ? { directory: params.cwd } : {}),
order: "desc",
limit: 100,
...(params.cursor ? { cursor: params.cursor } : {}),
})
return {
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: session.title,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
const turn = active.get(params.sessionId)
if (turn) {
turn.cancelled = true
turn.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
return {}
},
forkSession: async (params) => {
const forked = await input.client.session.fork({ sessionID: params.sessionId })
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
return { sessionId: state.id, configOptions: configOptions(state) }
},
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 = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
}
case "effort": {
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (!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)
return {}
},
setSessionModel: async (params) => {
const state = await requireSession(params.sessionId)
const selected = requireModel(state.catalog, params.modelId)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
return {}
},
prompt: async (params) => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
throw new ACPError.ServiceFailureError({
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
service: "session",
})
}
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
active.set(state.id, control)
const response = await streamTurn({
client: input.client,
connection: input.connection,
sessionID: state.id,
cwd: state.cwd,
start: prepared.start,
userMessageID: params.messageId,
control,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
}).finally(() => {
if (active.get(state.id) === control) active.delete(state.id)
})
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
},
cancel: async (params) => {
const current = active.get(params.sessionId)
if (current) {
current.cancelled = true
current.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
},
}
}
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
const parts = promptContentToParts(prompt)
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
const slash = detectSlashCommand(text)
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
const skill = slash ? catalog.skills.find((item) => item.name === slash.name) : undefined
const start = turnStart(messageID, slash, skill)
return { start, text, files, synthetic, slash, command, skill }
}
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
if (prompt.synthetic.length > 0) {
await client.session.synthetic({
sessionID: session.id,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
})
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id })
if (prompt.command) {
return client.session.command(
{
sessionID: session.id,
id: prompt.start.id,
command: prompt.command.name,
arguments: prompt.slash?.args,
files: prompt.files,
delivery: "steer",
},
{ signal },
)
}
return client.session.prompt(
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
{ signal },
)
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: SkillInfo | undefined): TurnStart {
if (slash?.name === "compact") return { type: "compaction", id: messageID }
if (skill) return { type: "skill", id: messageID }
return { type: "input", id: messageID }
}
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult, skillResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
client.skill.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const defaultModel = defaultResult.data ?? 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 ?? defaultModel.variants[0]?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
skills: skillResult.data.filter((skill) => skill.slash !== false),
}
}
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): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
)
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
return { providerID: model.providerID, id: model.id, variant: selected.variant }
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
state.modeID = modeID
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
}
async function getSession(client: OpenCodeClient, sessionID: string) {
return client.session.get({ sessionID }).catch((error) => {
if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
throw error
})
}
async function messages(client: OpenCodeClient, sessionID: string) {
const result: SessionMessageInfo[] = []
let cursor: string | undefined
do {
const page = cursor
? await client.message.list({ sessionID, limit: 200, cursor })
: await client.message.list({ sessionID, limit: 200, order: "asc" })
result.push(...page.data)
cursor = page.cursor.next ?? undefined
} while (cursor)
return result
}
async function registerMcpServers(
client: OpenCodeClient,
registered: Map<string, Set<string>>,
session: Attached,
servers: readonly McpServer[],
) {
const current = registered.get(session.id) ?? new Set<string>()
registered.set(session.id, current)
await Promise.all(
servers.flatMap((server) => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (current.has(key)) return []
current.add(key)
return [
client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
current.delete(key)
throw error
}),
]
}),
)
}
function mcpConfig(server: McpServer) {
if ("type" in server) {
return {
type: "remote" as const,
url: server.url,
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
oauth: false as const,
}
}
return {
type: "local" as const,
command: [server.command, ...server.args],
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
}
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
if (!value || typeof value !== "object") return JSON.stringify(value)
return `{${Object.entries(value)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`
}
async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, session: Attached, used?: number) {
if (!used) return
const model = session.catalog.models.find(
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
)
if (!model?.limit.context) return
const info = await client.session.get({ sessionID: session.id })
await connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "usage_update",
used,
size: model.limit.context,
cost: { amount: info.cost, currency: "USD" },
},
})
}
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
const value = text.trim()
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
export * as ACPService from "./service"
+222
View File
@@ -0,0 +1,222 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
>
export function toToolKind(toolName: string): ToolKind {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "apply_patch":
case "patch":
case "write":
return "edit"
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "read":
return "read"
case "task":
case "subagent":
return "think"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return locationFrom(input.filePath ?? input.filepath)
case "external_directory":
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
default:
return []
}
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly cwd?: string
}): ToolCall {
return {
toolCallId: input.toolCallId,
title: toolTitle(input.toolName, input.state.input, input.state.title),
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly content?: ToolContent
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
...(input.content?.length ? { content: toolContent(input.content) } : {}),
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
? normalized.filter((part) => !images.includes(part))
: [{ type: "content" as const, content: { type: "text" as const, text: read } }]
const oldText = stringValue(input.input.oldString)
const newText = stringValue(input.input.newString)
const diff: ToolCallContent[] =
oldText === undefined || newText === undefined
? []
: [
{
type: "diff",
path: stringValue(input.input.path) ?? stringValue(input.input.filePath) ?? "",
oldText,
newText,
},
]
return {
toolCallId: input.toolCallId,
status: "completed",
content: [...primary, ...diff, ...images],
rawOutput: {
structured: input.structured,
...(input.result === undefined ? {} : { result: input.result }),
},
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.input, undefined),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: { structured: input.structured, error: input.error },
}
}
function toolContent(content: ToolContent): ToolCallContent[] {
return content.flatMap((part): ToolCallContent[] => {
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
if (!match?.[1]?.startsWith("image/") || match[2] === undefined) return []
return [{ type: "content", content: { type: "image", mimeType: match[1], data: match[2] } }]
})
}
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
if (typeof structured.content === "string") {
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
}
if (!Array.isArray(structured.entries)) return undefined
return structured.entries
.flatMap((entry): string[] => {
if (typeof entry === "string") return [entry]
if (!entry || typeof entry !== "object") return []
const path = Reflect.get(entry, "path")
return typeof path === "string" ? [path] : []
})
.join("\n")
}
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
return fallback || toolName
}
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
if (!isShell(toolName) || input.cwd || input.workdir) return input
const workdir = shellWorkdir(input, cwd)
return workdir ? { ...input, cwd: workdir } : input
}
function shellWorkdir(input: ToolInput, cwd?: string) {
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
if (!explicit) return cwd
return isAbsolute(explicit) ? explicit : resolve(cwd ?? process.cwd(), explicit)
}
function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
function locationFrom(...values: unknown[]): ToolCallLocation[] {
return Array.from(
new Set(
values.flatMap((value): string[] => {
if (Array.isArray(value))
return value.filter((item): item is string => typeof item === "string" && item.length > 0)
const path = stringValue(value)
return path ? [path] : []
}),
),
(path) => ({ path }),
)
}
export function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPTool from "./tool"
+1
View File
@@ -34,6 +34,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
),
},
commands: [
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
params: {
+36
View File
@@ -0,0 +1,36 @@
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect } from "effect"
import { ACP } from "../../acp/agent"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(
Commands.commands.acp,
Effect.fn("cli.acp")(function* () {
process.env.OPENCODE_CLIENT = "acp"
const endpoint = yield* Standalone.start()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const input = new WritableStream<Uint8Array>({
write: (chunk) =>
new Promise<void>((resolve, reject) => {
process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
}),
})
const output = new ReadableStream<Uint8Array>({
start(controller) {
process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)))
process.stdin.on("end", () => controller.close())
process.stdin.on("error", (error) => controller.error(error))
},
})
const stream = ndJsonStream(input, output)
const connection = new AgentSideConnection((connection) => ACP.create(client, connection), stream)
process.stdin.resume()
yield* Effect.promise(() => connection.closed)
// EOF owns this stdio process; exiting also closes the private server's lease pipe.
yield* Effect.sync(() => process.exit(0))
}),
)
@@ -1,7 +1,7 @@
import { Cause, Effect, Exit, Option } from "effect"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { AppProcess } from "@opencode-ai/core/process"
import { AppProcess } from "@opencode-ai/util/process"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@@ -1,5 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -8,7 +8,7 @@ import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -3,7 +3,7 @@ import path from "node:path"
import { readFile, stat, writeFile } from "node:fs/promises"
import { Effect, Option } from "effect"
import { applyEdits, modify } from "jsonc-parser"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
@@ -32,6 +32,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
tuiConfig: resolved,
config: {
update: (update) => runServicePromise(config.update(update)),
},
}),
)
}),
+1 -1
View File
@@ -1,6 +1,6 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
+2 -2
View File
@@ -1,10 +1,10 @@
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
+10 -9
View File
@@ -1,20 +1,22 @@
#!/usr/bin/env bun
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/core/observability"
import { Observability } from "@opencode-ai/util/observability"
import { Client } from "@opencode-ai/util/client"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
connect: () => import("./commands/handlers/auth/connect"),
@@ -72,8 +74,7 @@ Effect.logInfo("cli starting", {
Observability.layer({
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? "cli",
}),
}).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))),
),
Effect.provide(NodeServices.layer),
Effect.scoped,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
+2
View File
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
replayLimit?: number
demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"]
}
type Model = MiniFrontendInput["model"]
@@ -119,6 +120,7 @@ export async function runMini(input: MiniCommandInput) {
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
config: input.config,
})
})
if (result.exitCode !== 0) process.exit(result.exitCode)
+184 -19
View File
@@ -5,6 +5,7 @@ import type {
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
@@ -75,6 +76,9 @@ export async function runNonInteractivePrompt(input: Input) {
const messageID = SessionMessage.ID.create()
const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>()
const renderedText = new Map<string, string>()
const renderedReasoning = new Map<string, string>()
const renderedTools = new Set<string>()
let submitted = false
let promoted = false
let emittedError = false
@@ -82,6 +86,8 @@ export async function runNonInteractivePrompt(input: Input) {
let formCancelled = false
let interrupted = false
let v1InvalidOutput = false
let prePromotionError: { message: string; [key: string]: unknown } | undefined
let finalizing = false
let admission: AbortController | undefined
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
@@ -104,6 +110,17 @@ export async function runNonInteractivePrompt(input: Input) {
UI.empty()
}
const writeReasoning = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
if (emit("reasoning", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) return void process.stdout.write(line + EOL)
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
}
const flushStep = () => {
if (!pendingStep) return
const value = pendingStep
@@ -181,6 +198,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.input.promoted") {
if (event.data.inputID === messageID) {
promoted = true
prePromotionError = undefined
continue
}
}
@@ -191,7 +209,12 @@ export async function runNonInteractivePrompt(input: Input) {
) {
return
}
if (!promoted && event.type === "session.execution.failed") {
prePromotionError = event.data.error
continue
}
if (!promoted) continue
if (finalizing) continue
if (event.type === "session.step.started") {
const part = {
@@ -219,12 +242,16 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.text.started") {
flushStep()
starts.set("text", { id: partID(event.id), timestamp: time })
starts.set(`text\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
id: partID(event.id),
timestamp: time,
})
continue
}
if (event.type === "session.text.ended") {
const started = starts.get("text")
starts.delete("text")
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
const started = starts.get(`text\u0000${key}`)
starts.delete(`text\u0000${key}`)
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@@ -233,18 +260,23 @@ export async function runNonInteractivePrompt(input: Input) {
text: event.data.text,
time: { start: started?.timestamp ?? time, end: time },
}
renderedText.set(key, event.data.text)
writeText(part, time)
continue
}
if (event.type === "session.reasoning.started") {
flushStep()
starts.set("reasoning", { id: partID(event.id), timestamp: time })
starts.set(`reasoning\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
id: partID(event.id),
timestamp: time,
})
continue
}
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning")
starts.delete("reasoning")
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
const started = starts.get(`reasoning\u0000${key}`)
starts.delete(`reasoning\u0000${key}`)
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@@ -254,17 +286,8 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: event.data.state,
time: { start: started?.timestamp ?? time, end: time },
}
if (emit("reasoning", time, { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) {
process.stdout.write(line + EOL)
continue
}
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
renderedReasoning.set(key, event.data.text)
writeReasoning(part, time)
continue
}
@@ -360,6 +383,7 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(key)
renderedTools.add(key)
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
continue
}
@@ -405,6 +429,7 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(key)
renderedTools.add(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, current.content).trim())
@@ -480,6 +505,122 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const projectedMessages = async () => {
const messages: SessionMessageInfo[] = []
let cursor: string | undefined
while (true) {
const page = await input.client.message.list(
cursor
? { sessionID: input.sessionID, limit: 200, cursor }
: { sessionID: input.sessionID, limit: 200, order: "desc" },
)
for (const message of page.data) {
if (message.id === messageID) return { found: true, messages: messages.toReversed() }
messages.push(message)
}
cursor = page.cursor.next ?? undefined
if (!cursor) return { found: false, messages: [] }
}
}
const reconcile = async () => {
const projected = await projectedMessages()
for (const message of projected.messages) {
if (message.type !== "assistant") continue
const timestamp = message.time.completed ?? message.time.created
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
if (item.type === "text") {
const ordinal = textOrdinal++
const key = contentKey(message.id, ordinal)
const rendered = renderedText.get(key) ?? ""
if (rendered === item.text || !item.text.startsWith(rendered)) continue
const text = item.text.slice(rendered.length)
writeText(
{
id: projectedPartID(message.id, `text-${ordinal}`),
sessionID: input.sessionID,
messageID: message.id,
type: "text",
text,
time: { start: message.time.created, end: timestamp },
},
timestamp,
)
renderedText.set(key, item.text)
continue
}
if (item.type === "reasoning") {
const ordinal = reasoningOrdinal++
if (!input.thinking) continue
const key = contentKey(message.id, ordinal)
const rendered = renderedReasoning.get(key) ?? ""
if (rendered === item.text || !item.text.startsWith(rendered)) continue
const text = item.text.slice(rendered.length)
const part = {
id: projectedPartID(message.id, `reasoning-${ordinal}`),
sessionID: input.sessionID,
messageID: message.id,
type: "reasoning",
text,
metadata: item.state,
time: { start: message.time.created, end: timestamp },
}
renderedReasoning.set(key, item.text)
writeReasoning(part, timestamp)
continue
}
const key = toolKey(message.id, item.id)
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
const part: MiniToolPart = {
id: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID,
messageID: message.id,
type: "tool",
callID: item.id,
tool: item.name,
state:
item.state.status === "completed"
? {
status: "completed",
input: item.state.input,
output: toolOutputText(item.name, item.state.content),
title: item.name,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
}
: {
status: "error",
input: item.state.input,
error: item.state.error.message,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
},
}
renderedTools.add(key)
if (emit("tool_use", timestamp, { part })) continue
if (item.state.status === "completed") {
await input.renderTool(item)
continue
}
if (toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
}
await input.renderToolError(item)
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
}
}
return projected.found
}
const interrupt = () => {
if (interrupted) process.exit(130)
interrupted = true
@@ -559,11 +700,27 @@ export async function runNonInteractivePrompt(input: Input) {
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
: []),
])
await completed
if (input.compatibility === "v1") {
await completed
return
}
const waiting = input.client.session.wait({ sessionID: input.sessionID })
await Promise.race([waiting, completed.then(() => waiting)])
finalizing = true
controller.abort()
const found = await reconcile()
if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
emittedError = true
process.exitCode = 1
if (!emit("error", Date.now(), { error })) UI.error(error.message)
}
} finally {
process.off("SIGINT", interrupt)
controller.abort()
await stream.return?.(undefined).catch(() => {})
if (input.compatibility === "v1") await stream.return?.(undefined).catch(() => {})
else void stream.return?.(undefined).catch(() => {})
}
}
@@ -595,6 +752,14 @@ function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function contentKey(messageID: string, ordinal: number) {
return `${messageID}\u0000${ordinal}`
}
function projectedPartID(messageID: string, part: string) {
return `prt_${messageID.replace(/^msg_/, "")}_${part}`
}
function fallbackTool(event: {
id: string
created: number
+1 -1
View File
@@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
+11 -5
View File
@@ -2,10 +2,10 @@ export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
@@ -75,7 +75,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
password,
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: process.env.OPENCODE_DB,
path:
process.env.OPENCODE_DB ??
(["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
@@ -1,6 +1,6 @@
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { Effect, Redacted } from "effect"
import { Env } from "../env"
import { ServiceConfig } from "./service-config"
+29 -12
View File
@@ -1,6 +1,6 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Hash } from "@opencode-ai/core/util/hash"
import { Global } from "@opencode-ai/util/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
import { Hash } from "@opencode-ai/util/hash"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
@@ -25,17 +25,21 @@ const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = InstallationChannel) {
if (channel === "latest") return "service.json"
if (channel === "local") return "service-local.json"
return `service-${Hash.fast(channel)}.json`
if (channel === "latest" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function defaultPort(channel = InstallationChannel) {
if (channel === "latest") return 0xc0de
if (channel === "latest" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
}
export function legacyFilename(channel = InstallationChannel) {
if (channel === "latest" || channel === "local") return
return `service-${Hash.fast(channel)}.json`
}
export function versionBelongsToChannel(
version: string | undefined,
channel = InstallationChannel,
@@ -54,7 +58,6 @@ export const migrateRegistration = Effect.fnUntraced(function* (
channel = InstallationChannel,
installedVersion = InstallationVersion,
) {
if (channel === "latest" || channel === "local") return
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
@@ -64,6 +67,14 @@ export const migrateRegistration = Effect.fnUntraced(function* (
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
if (Option.isNone(yield* decodeInfo(text.value).pipe(Effect.option))) return
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password") return key
throw new Error(`Unknown service config key: ${key}`)
@@ -73,18 +84,23 @@ const paths = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const name = filename()
const legacy = legacyFilename()
const file = path.join(global.state, name)
return {
fs,
file,
legacyFile: path.join(global.state, "service.json"),
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
legacyRegistrationFiles: [
...(legacy ? [path.join(global.state, legacy)] : []),
...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []),
],
configFile: path.join(global.config, name),
}
})
export const options = Effect.fnUntraced(function* () {
const { file, legacyFile } = yield* paths
yield* migrateRegistration(legacyFile, file)
const { file, legacyRegistrationFiles } = yield* paths
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: InstallationVersion,
@@ -93,7 +109,8 @@ export const options = Effect.fnUntraced(function* () {
})
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* paths
const { fs, configFile, legacyConfigFile } = yield* paths
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),
+2 -2
View File
@@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
@@ -3,7 +3,7 @@
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { go } from "@opencode-ai/tui/logo"
+3 -3
View File
@@ -1,10 +1,10 @@
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import {
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/core/installation/version"
} from "@opencode-ai/util/installation/version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "node:path"
type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown }
const children: Bun.Subprocess[] = []
afterEach(async () => {
await Promise.all(
children.splice(0).map(async (child) => {
child.kill("SIGKILL")
await child.exited
}),
)
})
describe("acp command", () => {
test("is registered", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
})
test("initializes over ndjson and exits on stdin eof", async () => {
const child = spawn()
const stderr = new Response(child.stderr).text()
await child.stdin.write(
new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: 1,
clientCapabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}) + "\n",
),
)
await child.stdin.flush()
const response = await readMessage(child.stdout)
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
expect(response.result).toMatchObject({
protocolVersion: 1,
agentCapabilities: { loadSession: true },
agentInfo: { name: "OpenCode" },
})
await child.stdin.end()
const exitCode = await child.exited
const errorOutput = await stderr
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
children.splice(children.indexOf(child), 1)
}, 30_000)
})
function spawn() {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
cwd: path.join(import.meta.dir, "../.."),
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
children.push(child)
return child
}
async function readMessage(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let output = ""
while (true) {
const result = await Promise.race([
reader.read(),
Bun.sleep(20_000).then(() => {
throw new Error("timed out waiting for ACP response")
}),
])
if (result.done) throw new Error(`ACP exited before responding: ${output}`)
output += decoder.decode(result.value, { stream: true })
const newline = output.indexOf("\n")
if (newline === -1) continue
reader.releaseLock()
const message: unknown = JSON.parse(output.slice(0, newline))
if (!isMessage(message)) throw new Error(`invalid ACP response: ${output.slice(0, newline)}`)
return message
}
}
function isMessage(value: unknown): value is Message {
return typeof value === "object" && value !== null
}
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, "../.."),
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, test } from "bun:test"
import {
buildConfigOptions,
buildEffortSelectOption,
buildModeSelectOption,
buildModelSelectOption,
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
} from "../../src/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
id: "anthropic",
name: "Anthropic",
models: [
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
{ id: "claude-haiku", name: "Claude Haiku" },
],
},
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
]
describe("acp config options", () => {
test("builds the model select option with ACP verifier category", () => {
expect(
buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
}),
).toEqual({
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "anthropic/claude/sonnet-4",
options: [
{ value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
{ value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
],
})
})
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: "default",
options: [
{ value: "low", name: "Low" },
{ value: "default", name: "Default" },
{ value: "high", name: "High" },
],
})
})
test("effort fallback uses the first variant when default is absent", () => {
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
"minimal",
)
})
test("builds the mode select option with descriptions when present", () => {
expect(
buildModeSelectOption({
currentModeId: "build",
modes: [
{ id: "build", name: "Build", description: "Make code changes" },
{ id: "plan", name: "Plan" },
],
}),
).toEqual({
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: "build",
options: [
{ value: "build", name: "Build", description: "Make code changes" },
{ value: "plan", name: "Plan" },
],
})
})
test("builds full config options with model, effort, and mode in stable order", () => {
const options = buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "very-high",
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
currentModeId: "plan",
})
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
expect(options[0]?.currentValue).toBe("anthropic/claude/sonnet-4")
expect(options[1]?.currentValue).toBe("very-high")
})
test("full config options omit effort for models without variants", () => {
expect(
buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
}).map((option) => option.id),
).toEqual(["model"])
})
test("parses provider/model selections", () => {
expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
})
})
test("parses provider/model/variant selections when the base model exposes that variant", () => {
expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
})
})
test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
})
})
test("parses trailing variants for slash-containing model ids", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "high",
})
})
test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
})
})
test("formats variant names for display", () => {
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
})
})
@@ -0,0 +1,74 @@
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import {
alternateValue,
createAcpFixture,
expectOk,
flattenSelectOptions,
initialize,
newSession,
requireSelectOption,
selectConfigOption,
} from "./subprocess"
describe("acp config option subprocess", () => {
test('model option is listed with category "model"', async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const model = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "model")
expect(model.category).toBe("model")
expect(model.currentValue).toBe("test/test-model")
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
}, 60_000)
test("model switch updates currentValue", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const model = requireSelectOption(session.configOptions, "model")
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
expect(nextModel).toBe("test/second-model")
const updated = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}, 60_000)
test('effort option is listed with category "thought_level" when selected model supports variants', async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}, 60_000)
test("effort switch updates currentValue", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const nextEffort = alternateValue(requireSelectOption(session.configOptions, "effort"))
const updated = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}, 60_000)
})
+239
View File
@@ -0,0 +1,239 @@
import { describe, expect, test } from "bun:test"
import { pathToFileURL } from "node:url"
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
describe("acp content conversion", () => {
test("plain text block becomes a text part", () => {
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
})
test("assistant-only text audience becomes synthetic", () => {
expect(
contentBlockToParts({
type: "text",
text: "internal",
annotations: { audience: ["assistant"] },
}),
).toEqual([{ type: "text", text: "internal", synthetic: true }])
})
test("user-only text audience becomes ignored", () => {
expect(
contentBlockToParts({
type: "text",
text: "visible to user",
annotations: { audience: ["user"] },
}),
).toEqual([{ type: "text", text: "visible to user", ignored: true }])
})
test("image block with base64 data becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "AAAA",
mimeType: "image/png",
uri: "file:///tmp/screenshot.png",
}),
).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "screenshot.png",
mime: "image/png",
},
])
})
test("image block with http URI becomes a file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/jpeg",
uri: "http://example.com/assets/photo.jpg",
}),
).toEqual([
{
type: "file",
url: "http://example.com/assets/photo.jpg",
filename: "photo.jpg",
mime: "image/jpeg",
},
])
})
test("resource_link file URL becomes a file part with name and fallback mime", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "file:///tmp/notes.txt",
name: "client-notes.txt",
}),
).toEqual([
{
type: "file",
url: "file:///tmp/notes.txt",
filename: "client-notes.txt",
mime: "text/plain",
},
])
})
test("resource_link zed path becomes a file URL part", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "zed://workspace?path=/tmp/project/src/app.ts",
name: "app.ts",
mimeType: "text/typescript",
}),
).toEqual([
{
type: "file",
url: pathToFileURL("/tmp/project/src/app.ts").href,
filename: "app.ts",
mime: "text/typescript",
},
])
})
test("resource with text becomes a sourced text part", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt#L12-L14",
mimeType: "text/plain",
text: "context",
},
})
expect(result).toHaveLength(1)
expect(result[0]?.type).toBe("text")
if (result[0]?.type === "text") {
expect(result[0].text.endsWith("\ncontext")).toBe(true)
expect(result[0].text.includes("context.txt")).toBe(true)
expect(result[0].text.includes("12")).toBe(true)
}
})
test("resource with text uses URI fallback for non-file resources", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "mcp://server/context",
text: "context",
},
}),
).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
})
test("resource with text includes file path", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt",
mimeType: "text/plain",
text: "context",
},
})
expect(result).toHaveLength(1)
expect(result[0]?.type).toBe("text")
if (result[0]?.type === "text") {
expect(result[0].text.endsWith("\ncontext")).toBe(true)
expect(result[0].text.includes("context.txt")).toBe(true)
}
})
test("resource with blob and mimeType becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/report.pdf",
mimeType: "application/pdf",
blob: "JVBERg==",
},
}),
).toEqual([
{
type: "file",
url: "data:application/pdf;base64,JVBERg==",
filename: "report.pdf",
mime: "application/pdf",
},
])
})
test("data URL resource is preserved as a file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "data:text/plain;base64,aGVsbG8=",
mimeType: "text/plain",
blob: "ignored",
},
}),
).toEqual([
{
type: "file",
url: "data:text/plain;base64,aGVsbG8=",
filename: "file",
mime: "text/plain",
},
])
})
test("unsupported blocks are ignored", () => {
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
expect(
promptContentToParts([
// @ts-expect-error Exercise forward compatibility with an unknown ACP content block.
{ type: "unknown", text: "skip" },
]),
).toEqual([])
})
})
describe("acp replay conversion", () => {
test("replays text audience annotations", () => {
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
{
content: {
type: "text",
text: "cached",
annotations: { audience: ["assistant"] },
},
},
])
})
test("replays file and data URL parts as ACP content", () => {
expect(
partsToContentChunks([
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
]),
).toEqual([
{
content: {
type: "resource_link",
uri: "file:///tmp/readme.md",
name: "readme.md",
mimeType: "text/markdown",
},
},
{
content: {
type: "resource",
resource: {
uri: pathToFileURL("note.txt").href,
mimeType: "text/plain",
text: "hello",
},
},
},
])
})
})
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import { ACPError } from "../../src/acp/error"
describe("acp errors", () => {
test("maps validation failures to invalid params", () => {
const cases: ACPError.Error[] = [
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
new ACPError.InvalidEffortError({ effort: "extreme" }),
new ACPError.InvalidModeError({ mode: "turbo" }),
]
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602])
})
test("includes safe validation details", () => {
expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
code: -32602,
data: { sessionId: "ses_123" },
})
expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
code: -32602,
data: { modelId: "gpt-missing" },
})
})
test("maps auth required to the SDK auth error", () => {
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError())
expect(requestError).toBeInstanceOf(RequestError)
expect(requestError.code).toBe(-32000)
expect(requestError.message).toBe("Authentication required: provider authentication required")
expect(requestError.data).toEqual({})
})
test("maps service failures to safe internal errors", () => {
const requestError = ACPError.toRequestError(
new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
)
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Provider request failed")
expect(requestError.data).toEqual({ service: "provider" })
})
test("wraps unknown defects without leaking raw details", () => {
const requestError = ACPError.toRequestError(
ACPError.fromUnknown(new Error("stack has sk-ant-secret and oauth refresh token")),
)
const serialized = JSON.stringify(requestError.toErrorResponse())
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Internal service failure")
expect(serialized).not.toContain("sk-ant-secret")
expect(serialized).not.toContain("oauth refresh token")
expect(serialized).not.toContain("stack")
})
})
@@ -0,0 +1,684 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { resolve } from "node:path"
import { replayMessages, streamTurn, 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.input.promoted", { sessionID: "ses_b", inputID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_wrong_input",
ordinal: 0,
delta: "wrong input",
}),
)
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: 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",
inputID: "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 text and reasoning order before returning the terminal response", 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.input.promoted", { sessionID: "ses_order", inputID: id }))
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: "think-1",
}),
)
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: 2,
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 === 3) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "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.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "think-1"],
["agent_message_chunk", "answer"],
["agent_thought_chunk", "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 } })
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
await result.catch(() => undefined)
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.input.promoted", { sessionID: "ses_tools", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
name: "shell",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
)
send(
durableEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { phase: 1 },
content: [{ type: "text", text: "working" }],
}),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { exit: 0 },
content: [{ type: "text", text: "done" }],
result: { code: 0 },
executed: true,
}),
)
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
name: "read",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
input: { filePath: "/workspace/missing.ts" },
executed: false,
}),
)
send(
durableEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
structured: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
}),
)
send(
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
error: { type: "tool.error", message: "not found" },
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",
inputID: "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).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "working" } }],
})
expect(updates[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { structured: { exit: 0 }, result: { code: 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: { structured: { 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: { structured: { exit: 0 }, result: { code: 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.input.promoted", { sessionID: "ses_cancel", inputID: id }))
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
},
})
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
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()
await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
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" },
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.input.promoted", { sessionID: "ses_form", inputID: 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",
inputID: "input_form",
})
expect(response.stopReason).toBe("end_turn")
expect(
fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
).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 inputID: string
}) {
return streamTurn({
client: input.fixture.client,
connection: input.connection,
sessionID: input.sessionID,
cwd: "/workspace",
start: { type: "input", id: input.inputID },
userMessageID: `client_${input.inputID}`,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
})
}
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" },
structured: { exit: 0 },
content: [
{ type: "text", text: "done" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
],
result: { code: 0 },
},
},
{
type: "tool",
id: "call_running",
name: "shell",
time: { created: 2, ran: 2 },
state: {
status: "running",
input: { command: "pwd" },
structured: {},
content: [{ type: "text", text: "/workspace" }],
},
},
{
type: "tool",
id: "call_failed",
name: "read",
time: { created: 2, completed: 3 },
state: {
status: "error",
input: { filePath: "/workspace/missing.ts" },
structured: { 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" },
structured: { exit: 0 },
content: [{ type: "text", text: "done" }],
},
},
],
} satisfies SessionMessageInfo
}
+124
View File
@@ -0,0 +1,124 @@
import { expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/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 = Reflect.get(body, "id")
if (typeof id !== "string") return new Response(null, { status: 400 })
queueMicrotask(() => {
if (!events) return
send(events, {
id: "evt_promoted",
created: 1,
type: "session.input.promoted",
data: { sessionID: "ses_test", inputID: 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 userMessageID = "client-message"
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 },
userMessageID,
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", userMessageId: userMessageID, 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`))
}
})
@@ -0,0 +1,49 @@
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize } from "./subprocess"
describe("acp initialize/auth subprocess", () => {
test("initialize responds with capabilities", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
}, 60_000)
test("auth negotiation is explicit and safe", async () => {
await using fixture = await createAcpFixture()
const secret = "subprocess-auth-secret"
const acp = fixture.spawn({ OPENCODE_AUTH_CONTENT: secret })
const initialized = await initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
expect(expectOk(await acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" }))).toEqual(
{},
)
const rejected = await acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expect(rejected.error?.code).toBe(-32602)
expect(JSON.stringify(rejected.error)).not.toContain(secret)
}, 60_000)
test("initialize without terminal-auth metadata keeps auth command implicit", async () => {
await using fixture = await createAcpFixture()
const initialized = expectOk(
await fixture.spawn().request<InitializeResponse>("initialize", { protocolVersion: 1 }),
)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
}, 60_000)
})
@@ -0,0 +1,85 @@
import type {
CloseSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption } from "./subprocess"
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
await using fixture = await createAcpFixture()
expect(await fixture.spawn().close()).toBe(0)
}, 60_000)
test("close capability and close request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = await newSession(acp, fixture.home)
expect(
expectOk(await acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId })),
).toEqual({})
}, 60_000)
test("new session succeeds on the first request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
expect((await newSession(acp, fixture.home)).sessionId).toStartWith("ses_")
}, 60_000)
test("loadSession capability and load request return session config options", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
const session = await newSession(acp, fixture.home)
const loaded = expectOk(
await acp.request<LoadSessionResponse>("session/load", {
cwd: fixture.home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
}, 60_000)
test("list request includes a live ACP-created session", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}, 60_000)
test("resume capability advertisement", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}, 60_000)
test("resume request returns session config options", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const resumed = expectOk(
await acp.request<ResumeSessionResponse>("session/resume", {
cwd: fixture.home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}, 60_000)
})
@@ -0,0 +1,499 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { streamTurn } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => {
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.input.promoted", { sessionID: "ses_allow", inputID: id }))
send(
permissionAsked("ses_allow", "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
}),
)
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { filePath: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", callID: "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: { filePath: "/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.input.promoted", { sessionID: "ses_external", inputID: 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("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: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
name: "edit",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "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", callID: "call_edit" },
}),
)
},
async onPermissionReply({ send }) {
await fs.writeFile(file, "after")
send(
durableEvent("session.tool.success", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
structured: { 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: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
name: "patch",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
input: { patchText },
executed: false,
}),
)
send(
permissionAsked("ses_patch", "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", callID: "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",
callID: "call_patch",
structured: { 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.input.promoted", { sessionID: "ses_reject", inputID: 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.input.promoted", { sessionID: "ses_serial", inputID: 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.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: 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()
}
})
})
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
return streamTurn({
client: fixture.client,
connection,
sessionID,
cwd,
start: { type: "input", id: inputID },
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, 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 callID: string }
} = {},
) {
return ephemeralEvent("permission.v2.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 = Reflect.get(request.body, "reply")
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
})
}
@@ -0,0 +1,62 @@
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("acp prompt content subprocess", () => {
test("accepts embedded text resource image and file resource link prompt content", async () => {
await using fixture = await createAcpFixture()
await Bun.write(path.join(fixture.home, "README.md"), "# ACP content smoke\n")
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this embedded resource." },
{
type: "resource",
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
},
],
}),
)
expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this image." },
{
type: "image",
mimeType: "image/png",
data: tinyPng,
},
],
}),
)
const linked = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this linked file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(fixture.home, "README.md")).href,
name: "README.md",
mimeType: "text/markdown",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(3)
}, 60_000)
})
@@ -0,0 +1,269 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
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", "/api/skill"].map((path) =>
fixture.requests
.filter((request) => request.path === path)
.map((request) => request.query["location[directory]"]),
),
).toEqual([
["/workspace", "/other"],
["/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", "verify"],
["review", "verify"],
["review", "verify"],
])
})
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.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
expect(currentValue(selectedModel, "effort")).toBe("low")
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" } },
{ model: { providerID: "test", id: testModel.id, variant: "high" } },
])
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
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("/api/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("/api/mcp/"))
expect(adds).toHaveLength(4)
expect(adds.filter((request) => request.path === "/api/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 === "/api/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
}
+207
View File
@@ -0,0 +1,207 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import {
OpenCode,
type AgentInfo,
type CommandInfo,
type ModelInfo,
type ModelRef,
type SessionInfo,
type SkillInfo,
type TokenUsageInfo,
} from "@opencode-ai/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[]
readonly skills?: readonly SkillInfo[]
}
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",
template: "",
} satisfies CommandInfo
export const verifySkill = {
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
} satisfies SkillInfo
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: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
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] })
}
if (request.path === "/api/skill") {
return Response.json({ location, data: options.skills ?? [verifySkill] })
}
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)
},
}
}
@@ -0,0 +1,232 @@
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("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: "/ignored",
sessionId: "ses_loaded",
mcpServers: [],
})
const resumed = await fixture.service.resumeSession({
cwd: "/ignored",
sessionId: "ses_resume",
mcpServers: [],
})
const forked = await fixture.service.forkSession({
cwd: "/ignored",
sessionId: "ses_loaded",
mcpServers: [],
})
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 interrupts best-effort", async () => {
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.endsWith("/interrupt")) {
return new Response(null, { status: 500 })
}
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")
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/missing/interrupt",
])
})
})
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
return result.configOptions?.find((option) => option.id === id)?.currentValue
}
+264
View File
@@ -0,0 +1,264 @@
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, skills, 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") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.input.promoted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: id.replace(/^msg_/, "evt_"),
type: "session.skill.activated",
data: { sessionID: "ses_routes", skill: "verify" },
})
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.compaction.admitted",
data: { sessionID: "ses_routes", inputID: 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,
messageId: "client-command",
prompt: [{ type: "text", text: "/review now" }],
})
const skillResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-skill",
prompt: [{ type: "text", text: "/verify" }],
})
const compactResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-compact",
prompt: [{ type: "text", text: "/compact" }],
})
expect([commandResult.stopReason, skillResult.stopReason, compactResult.stopReason]).toEqual([
"end_turn",
"end_turn",
"end_turn",
])
const command = fixture.requests.find((request) => request.path === "/api/session/ses_routes/command")
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
id: expect.any(String),
command: "review",
arguments: "now",
files: [],
delivery: "steer",
})
expect(skill?.body).toMatchObject({ id: expect.any(String), skill: "verify" })
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.input.promoted",
data: { sessionID: "ses_usage", inputID: 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,
messageId: "client-message",
prompt: [{ type: "text", text: "hello" }],
})
expect(response).toEqual({
stopReason: "end_turn",
userMessageId: "client-message",
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.input.promoted",
data: { sessionID: "ses_usage_failure", inputID: 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 = Reflect.get(request.body, "id")
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 },
})
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
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/skill") return Response.json({ location, data: [skill] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/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 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(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
path: "/api/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" }, { name: "verify", description: "Verify work" }],
},
})
} 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 skill = {
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
}
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,24 @@
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, initialize, newSession, verifierSkill } from "./subprocess"
describe("acp skills subprocess", () => {
test("skill slash command appears through available_commands_update", async () => {
await using fixture = await createAcpFixture({ skill: verifierSkill })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const update = await acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some(
(command) => command.name === "verifier-skill" && command.description.length > 0,
),
)
expect(update.params.sessionId).toBe(session.sessionId)
}, 60_000)
})
+207
View File
@@ -0,0 +1,207 @@
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode-ai/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
}) => void | Promise<void>
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, "reply")
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\/([^/]+)\/cancel$/.exec(url.pathname)
if (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]) {
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
return new Response(null, { status: 204 })
}
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 = Reflect.get(value, 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
}
+384
View File
@@ -0,0 +1,384 @@
import type {
InitializeResponse,
NewSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
type JsonRpcRequest = {
readonly jsonrpc: "2.0"
readonly id: number
readonly method: string
readonly params?: unknown
}
export type JsonRpcError = {
readonly code: number
readonly message?: string
readonly data?: unknown
}
export type JsonRpcResponse<T> = {
readonly jsonrpc: "2.0"
readonly id: number
readonly result?: T
readonly error?: JsonRpcError
}
type JsonRpcNotification<T> = {
readonly jsonrpc: "2.0"
readonly method: string
readonly params: T
}
type JsonRpcMessage = Record<string, unknown>
type Waiter = {
readonly predicate: (message: JsonRpcMessage) => boolean
readonly resolve: (message: JsonRpcMessage) => void
readonly reject: (error: Error) => void
readonly timer: ReturnType<typeof setTimeout>
}
export type AcpProcess = {
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs?: number,
) => Promise<JsonRpcNotification<T>>
readonly close: () => Promise<number>
readonly stderr: () => string
readonly [Symbol.asyncDispose]: () => Promise<void>
}
export const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
const skills = path.join(root, "skills")
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
if (options.skill) {
await fs.mkdir(path.join(skills, "verifier-skill"), { recursive: true })
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
}
const requests: unknown[] = []
const llm = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
return new Response("Not found", { status: 404 })
}
requests.push(await request.json().catch(() => undefined))
return new Response(completion("accepted"), {
headers: { "content-type": "text/event-stream" },
})
},
})
await Bun.write(
path.join(config, "opencode.json"),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
)
const processes = new Set<AcpProcess>()
return {
root,
home,
llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) {
const acp = spawnAcp({
env: {
...process.env,
HOME: root,
USERPROFILE: root,
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_DIR: config,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_MODELS_PATH: undefined,
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
...extraEnv,
},
})
processes.add(acp)
return acp
},
async [Symbol.asyncDispose]() {
await Promise.all([...processes].map((process) => process[Symbol.asyncDispose]()))
await llm.stop(true)
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
},
}
}
export function initialize(acp: AcpProcess) {
return acp
.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
})
.then(expectOk)
}
export function newSession(acp: AcpProcess, cwd: string) {
return acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }).then(expectOk)
}
export function expectOk<T>(response: JsonRpcResponse<T>) {
if (response.error) throw new Error(`ACP request failed: ${JSON.stringify(response.error)}`)
if (response.result === undefined) throw new Error("ACP response did not include a result")
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"] },
cost: { input: 0, output: 0 },
limit: { context: 100_000, output: 10_000 },
}
return {
autoupdate: false,
model: "test/test-model",
...(skills ? { skills: [skills] } : {}),
providers: {
test: {
name: "Test",
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "test-key", baseURL: llmUrl },
models: {
"test-model": {
...model,
name: "Test Model",
variants: [{ id: "low" }, { id: "high" }],
},
"second-model": {
...model,
name: "Second Test Model",
variants: [{ id: "medium" }, { id: "max" }],
},
},
},
},
}
}
function spawnAcp(input: { readonly env: Record<string, string | undefined> }): AcpProcess {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
cwd: path.join(import.meta.dir, "../.."),
env: input.env,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const errorDecoder = new TextDecoder()
const messages: JsonRpcMessage[] = []
const waiters: Waiter[] = []
let nextID = 1
let failure: Error | undefined
let stderr = ""
let inputClosed = false
let disposed = false
const fail = (error: Error) => {
if (failure) return
failure = error
waiters.splice(0).forEach((waiter) => {
clearTimeout(waiter.timer)
waiter.reject(error)
})
}
const dispatch = (message: JsonRpcMessage) => {
const index = waiters.findIndex((waiter) => waiter.predicate(message))
if (index === -1) {
messages.push(message)
return
}
const waiter = waiters.splice(index, 1)[0]
clearTimeout(waiter.timer)
waiter.resolve(message)
}
const output = (async () => {
const reader = child.stdout.getReader()
let buffered = ""
while (true) {
const chunk = await reader.read()
if (chunk.done) break
buffered += decoder.decode(chunk.value, { stream: true })
while (true) {
const newline = buffered.indexOf("\n")
if (newline === -1) break
const line = buffered.slice(0, newline).trim()
buffered = buffered.slice(newline + 1)
if (line) dispatch(parseMessage(line))
}
}
buffered += decoder.decode()
if (buffered.trim()) dispatch(parseMessage(buffered.trim()))
fail(new Error(`ACP exited before another response${stderr ? `: ${stderr}` : ""}`))
})().catch((error) => fail(asError(error)))
const errors = (async () => {
const reader = child.stderr.getReader()
while (true) {
const chunk = await reader.read()
if (chunk.done) break
stderr += errorDecoder.decode(chunk.value, { stream: true })
}
stderr += errorDecoder.decode()
})()
const take = (predicate: (message: JsonRpcMessage) => boolean, timeoutMs: number, description: string) => {
const index = messages.findIndex(predicate)
if (index !== -1) return Promise.resolve(messages.splice(index, 1)[0])
if (failure) return Promise.reject(failure)
return new Promise<JsonRpcMessage>((resolve, reject) => {
const waiter: Waiter = {
predicate,
resolve,
reject,
timer: setTimeout(() => {
const index = waiters.indexOf(waiter)
if (index !== -1) waiters.splice(index, 1)
reject(new Error(`Timed out waiting for ${description}${stderr ? `: ${stderr}` : ""}`))
}, timeoutMs),
}
waiters.push(waiter)
})
}
return {
async request<T>(method: string, params?: unknown) {
if (inputClosed) throw new Error("ACP stdin is closed")
const id = nextID++
const request: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
await child.stdin.write(encoder.encode(`${JSON.stringify(request)}\n`))
await child.stdin.flush()
const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
},
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
const notification = await take(
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
timeoutMs,
`${method} notification`,
)
if (!isNotification<T>(notification)) {
throw new Error(`Invalid ACP notification: ${JSON.stringify(notification)}`)
}
return notification
},
async close() {
if (!inputClosed) {
inputClosed = true
await child.stdin.end()
}
const exitCode = await withTimeout(child.exited, 5_000, "ACP did not exit after stdin EOF")
await Promise.all([output, errors])
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${stderr}`)
return exitCode
},
stderr: () => stderr,
async [Symbol.asyncDispose]() {
if (disposed) return
disposed = true
if (child.exitCode === null) child.kill("SIGKILL")
await child.exited
await Promise.all([output, errors])
},
}
}
function parseMessage(line: string): JsonRpcMessage {
const message: unknown = JSON.parse(line)
if (!isJsonRpcMessage(message)) throw new Error(`Invalid ACP message: ${line}`)
return message
}
function isJsonRpcMessage(message: unknown): message is JsonRpcMessage {
return !!message && typeof message === "object" && !Array.isArray(message)
}
function isResponse<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcResponse<T> {
return message.jsonrpc === "2.0" && typeof message.id === "number" && !("method" in message)
}
function isNotification<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcNotification<T> {
return message.jsonrpc === "2.0" && typeof message.method === "string" && !("id" in message)
}
function asError(error: unknown) {
return error instanceof Error ? error : new Error(String(error))
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
let timer: ReturnType<typeof setTimeout> | undefined
return Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
}),
]).finally(() => clearTimeout(timer))
}
function completion(text: string) {
const chunks = [
{ choices: [{ delta: { role: "assistant" }, finish_reason: null }], usage: null },
{ choices: [{ delta: { content: text }, finish_reason: null }], usage: null },
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
{
choices: [],
usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 },
},
]
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
}
+292
View File
@@ -0,0 +1,292 @@
import { resolve } from "node:path"
import { describe, expect, test } from "bun:test"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
toLocations,
toToolKind,
} from "../../src/acp/tool"
describe("acp tools", () => {
test("maps OpenCode tool ids to ACP tool kinds", () => {
expect(toToolKind("bash")).toBe("execute")
expect(toToolKind("shell")).toBe("execute")
expect(toToolKind("webfetch")).toBe("fetch")
expect(toToolKind("edit")).toBe("edit")
expect(toToolKind("apply_patch")).toBe("edit")
expect(toToolKind("patch")).toBe("edit")
expect(toToolKind("write")).toBe("edit")
expect(toToolKind("grep")).toBe("search")
expect(toToolKind("glob")).toBe("search")
expect(toToolKind("context7_resolve_library_id")).toBe("search")
expect(toToolKind("context7_get_library_docs")).toBe("search")
expect(toToolKind("read")).toBe("read")
expect(toToolKind("task")).toBe("think")
expect(toToolKind("custom_tool")).toBe("other")
})
test("extracts file locations from tool input", () => {
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
{ path: "/tmp/outside" },
])
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "subdir") },
])
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
test("builds completed content with text and image attachments", () => {
const image = Buffer.from("image-data").toString("base64")
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "edit",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
content: [
{ type: "text", text: "edited /tmp/file.ts" },
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
],
structured: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "text", text: "edited /tmp/file.ts" },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
{
type: "content",
content: { type: "image", mimeType: "image/png", data: image },
},
])
})
test("omits edit diffs when normalized content does not contain one", () => {
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "write",
input: {
filePath: "/tmp/file.ts",
content: "created",
},
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
structured: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "text", text: "wrote /tmp/file.ts" },
},
])
})
test("uses clean structured read content instead of model-facing formatting", () => {
expect(
completedToolUpdate({
toolCallId: "tool-read",
toolName: "read",
input: { path: "/tmp/file.ts" },
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
structured: {
type: "text-page",
content: "first\nsecond",
mime: "text/plain",
offset: 1,
truncated: false,
},
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
expect(
completedToolUpdate({
toolCallId: "tool-list",
toolName: "read",
input: { path: "/tmp" },
content: [],
structured: {
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
},
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
})
test("sends completed tool calls as partial updates", () => {
expect(
pendingToolCall({
toolCallId: "tool-1",
toolName: "edit",
state: {
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
},
}),
).toMatchObject({
toolCallId: "tool-1",
status: "pending",
kind: "edit",
locations: [{ path: "/tmp/file.ts" }],
rawInput: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
})
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "edit",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
content: [{ type: "text", text: "Edit applied successfully." }],
structured: { output: "Edit applied successfully." },
}),
).toEqual({
toolCallId: "tool-1",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "Edit applied successfully." },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
],
rawOutput: {
structured: { output: "Edit applied successfully." },
},
})
})
test("builds running tool updates with normalized content", () => {
expect(
runningToolUpdate({
toolCallId: "call",
toolName: "read",
state: { input: { filePath: "/tmp/a" } },
content: [{ type: "text", text: "done" }],
}),
).toMatchObject({
toolCallId: "call",
status: "in_progress",
content: [{ type: "content", content: { type: "text", text: "done" } }],
})
})
test("builds completed raw output with structured data and optional result", () => {
const attachments = [
{
type: "file",
mime: "image/jpeg",
name: "photo.jpg",
uri: "data:image/jpeg;base64,AAAA",
},
]
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [],
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
}).rawOutput,
).toEqual({
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
})
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [],
structured: { output: "done" },
}).rawOutput,
).toEqual({ structured: { output: "done" } })
})
test("extracts image attachments only from data URLs", () => {
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [
{ type: "file", mime: "image/webp", uri: "data:image/webp;charset=utf-8;base64,AAAA" },
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
],
structured: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
},
])
})
test("builds failed tool updates", () => {
expect(
errorToolUpdate({
toolCallId: "call",
toolName: "read",
input: { filePath: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
structured: { path: "/tmp/a" },
error: "failed",
}),
).toEqual({
toolCallId: "call",
status: "failed",
kind: "read",
title: "read",
locations: [{ path: "/tmp/a" }],
rawInput: { filePath: "/tmp/a" },
content: [
{ type: "content", content: { type: "text", text: "partial output" } },
{ type: "content", content: { type: "text", text: "failed" } },
],
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
})
})
})
+7 -2
View File
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { expect, test } from "bun:test"
import path from "path"
@@ -131,11 +131,16 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide" }
})
}),
)
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide" },
})
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally {
await Bun.$`rm -rf ${directory}`
@@ -5,6 +5,16 @@ import path from "node:path"
const root = path.resolve(import.meta.dir, "../../..")
describe("CLI frontend import boundaries", () => {
test("does not import Core directly", async () => {
const glob = new Bun.Glob("{src,test}/**/*.{ts,tsx}")
const imports: string[] = []
for await (const file of glob.scan({ cwd: path.join(root, "packages/cli") })) {
const source = await Bun.file(path.join(root, "packages/cli", file)).text()
if (/["']@opencode-ai\/core(?:\/[^"']*)?["']/.test(source)) imports.push(file)
}
expect(imports).toEqual([])
})
test("exposes only the intentional package entrypoints", async () => {
const run = await import("@opencode-ai/cli/run")
const mini = await import("@opencode-ai/tui/mini")
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import path from "node:path"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
+81 -1
View File
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import {
OpenCode,
type EventSubscribeOutput,
type SessionMessageAssistantTool,
type SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
type V2Event = EventSubscribeOutput
@@ -162,10 +167,13 @@ async function run(input: {
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inputID: string) => SessionMessageInfo[]
wait?: () => Promise<void>
}) {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
let wake: (() => void) | undefined
const wait = Promise.withResolvers<void>()
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) {
const value = values.shift()
@@ -175,6 +183,7 @@ async function run(input: {
})
continue
}
if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
yield value
}
})()
@@ -193,8 +202,19 @@ async function run(input: {
}) as never,
)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: input.messages?.(promptID) ?? [
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
],
cursor: {},
}),
)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
promptID = messageID
values.push(...input.turn(messageID))
wake?.()
wake = undefined
@@ -244,6 +264,63 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("uses session.wait then reconciles projected output without a terminal event", async () => {
const idle = Promise.withResolvers<void>()
let done = false
const task = capture({
format: "json",
turn: (messageID) => [prompted(messageID)],
wait: () => idle.promise,
messages: (messageID) => [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "projected answer" }],
finish: "stop",
time: { created: 2, completed: 3 },
},
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
],
}).then((output) => {
done = true
return output
})
await Bun.sleep(0)
await Bun.sleep(0)
expect(done).toBe(false)
idle.resolve()
const output = await task
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([expect.objectContaining({ type: "text", part: expect.objectContaining({ text: "projected answer" }) })])
})
test("reports an observed execution failure before prompt promotion", async () => {
const output = await capture({
format: "json",
turn: () => [executionFailed("instructions unavailable")],
messages: () => [],
})
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({
type: "error",
error: { type: "provider.transport", message: "instructions unavailable" },
}),
])
})
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
const sdk = await run({
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
@@ -307,6 +384,9 @@ describe("runNonInteractivePrompt", () => {
}),
])
expect(output.stderr).toBe("")
const sdk = await run({ compatibility: "v1", turn: (messageID) => [prompted(messageID), settled()] })
expect(sdk.session.wait).not.toHaveBeenCalled()
expect(sdk.message.list).not.toHaveBeenCalled()
})
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Scope } from "effect"
import fs from "node:fs/promises"
+25 -88
View File
@@ -1,18 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { expect, test } from "bun:test"
import { Effect, Schedule, Schema } from "effect"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -20,6 +11,7 @@ import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
@@ -43,17 +35,35 @@ test("local channel stores service config with the local service filename", asyn
}
})
test("service filenames isolate installation channels", () => {
test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("next")).toBe("service.json")
expect(ServiceConfig.filename("local")).toBe("service-local.json")
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("preview-b"))
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest"))
expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json")
expect(ServiceConfig.filename("preview/a")).toBe("service-preview-a.json")
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
})
test("service config migrates from the hashed channel filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
const target = path.join(root, ServiceConfig.filename("preview-a"))
try {
await fs.writeFile(legacy, JSON.stringify({ hostname: "127.0.0.2", port: 4098 }))
await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(target).json()).toEqual({ hostname: "127.0.0.2", port: 4098 })
await fs.writeFile(target, JSON.stringify({ port: 4099 }))
await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(target).json()).toEqual({ port: 4099 })
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("preview registration migration never moves stable discovery", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
const legacy = path.join(root, "service.json")
@@ -185,31 +195,6 @@ test("concurrent service processes elect one server", async () => {
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
const sessionID = SessionV2.ID.make("ses_service_recovery")
await withDatabase(
database,
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make(root), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "recovery",
directory: root,
title: "recovery",
version: "test",
time_suspended: Date.now(),
})
.run()
.pipe(Effect.orDie)
}),
)
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const registration = path.join(root, "state", "opencode", "service-local.json")
const port = await availablePort()
@@ -262,20 +247,6 @@ test("concurrent service processes elect one server", async () => {
contender.kill("SIGTERM")
await contender.exited
}
expect(
await withDatabase(
database,
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ timeSuspended: SessionTable.time_suspended })
.from(SessionTable)
.get()
.pipe(Effect.orDie)
}),
),
).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await winner?.exited
expect(await Bun.file(registration).exists()).toBe(false)
@@ -510,40 +481,6 @@ test("a failed service stays registered and owns the selected port until stopped
}
}, 30_000)
function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
return Effect.runPromise(effect.pipe(Effect.provide(Database.layer({ path: file })), Effect.scoped))
}
function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
return withDatabase(
file,
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ id: EventTable.id, sessionID: EventTable.aggregate_id, type: EventTable.type })
.from(EventTable)
.all()
.pipe(
Effect.orDie,
Effect.map((rows) =>
rows.filter(
(row) =>
row.sessionID === sessionID &&
row.type ===
EventV2.versionedType(
SessionEvent.Execution.Started.type,
SessionEvent.Execution.Started.durable.version,
),
),
),
Effect.filterOrFail((rows) => rows.length > 0),
Effect.map((rows) => rows.length),
Effect.retry(Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(200)])),
)
}),
)
}
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
for (let attempt = 0; attempt < 400; attempt++) {
const value = await Bun.file(file)
+1
View File
@@ -4,6 +4,7 @@
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
- State model-visible diagnostics, logs, tool descriptions, and instructions directly. The execution context is already clear; do not repeat `Code Mode` or `CodeMode` unless the distinction is necessary.
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
## OpenAPI
+7 -2
View File
@@ -36,7 +36,8 @@ ultimate source of truth.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Symbol primitive values and symbol-keyed properties.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
@@ -70,7 +71,11 @@ ultimate source of truth.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
- [x] Labeled statements, labeled `break`, and labeled `continue`.
- [ ] `for await...of` and async iteration.
- [x] `for await...of` over the supported synchronous collections and custom iterator objects using
`Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset.
## Functions and callbacks
+11 -12
View File
@@ -126,7 +126,7 @@ export const invokeIntrinsic = <R>(
if (ref.receiver instanceof CodeModeURLSearchParams) {
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available.`, node)
}
const coerceNumericArgument = <R>(
@@ -157,8 +157,7 @@ const coerceNumericArgument = <R>(
}
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
if (ref.namespace === "console")
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available.`, node)
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
@@ -168,9 +167,9 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
if (ref.namespace === "RegExp") return invokeRegExpStatic(ref.name, args, node)
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
}
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
}
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
@@ -348,7 +347,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
}
default:
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`String method '${name}' is not available.`, node)
}
return boundedData(result, `String.${name} result`)
}
@@ -364,7 +363,7 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
case "from":
return arrayFromItems(args[0], node)
default:
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Array.${name} is not available.`, node)
}
}
@@ -449,7 +448,7 @@ export const invokeGroupBy = <R>(
for (const item of items) {
const key = yield* coerceGroupByPropertyKey(runner, yield* apply([item, index]), node)
if (isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
}
const group = result[key]
if (group === undefined) result[key] = [item]
@@ -619,7 +618,7 @@ const invokeMapMethod = <R>(
})
}
default:
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Map method '${name}' is not available.`, node)
}
}
@@ -666,7 +665,7 @@ const invokeSetMethod = <R>(
case "isDisjointFrom":
return invokeSetOperation(runner, target, name, args[0], node)
default:
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Set method '${name}' is not available.`, node)
}
}
@@ -861,7 +860,7 @@ const invokeURLSearchParamsMethod = <R>(
})
}
default:
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available.`, node)
}
}
@@ -1104,7 +1103,7 @@ const invokeArrayMethod = <R>(
}
return -1
}
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Array method '${name}' is not available.`, node)
})
}
+8 -2
View File
@@ -36,7 +36,7 @@ export type StatementResult =
export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: string | number
key: PropertyKey
}
export class CodeModeFunction {
@@ -61,6 +61,12 @@ export class ComputedValue {
export class PromiseNamespace {}
export class SymbolNamespace {}
export const AsyncIteratorSymbol: unique symbol = Symbol("codemode.async-iterator")
export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
export class PromiseMethodReference {
@@ -166,7 +172,7 @@ export class InterpreterRuntimeError extends Error {
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`,
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
@@ -13,6 +13,7 @@ import {
PromiseMethodReference,
PromiseNamespace,
SearchFunction,
SymbolNamespace,
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
@@ -34,6 +35,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
isCodeModeValue(value)
function* childValues(value: object): Generator<unknown> {
@@ -113,7 +115,8 @@ export const typeofValue = (value: unknown): string => {
value instanceof PromiseInstanceMethodReference ||
value instanceof PromiseNamespace ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace
)
return "function"
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
+227 -96
View File
@@ -1,7 +1,8 @@
import { Cause, Effect } from "effect"
import { Cause, Effect, Exit } from "effect"
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import {
type AstNode,
AsyncIteratorSymbol,
asNode,
type Binding,
CodeModeFunction,
@@ -19,6 +20,8 @@ import {
IntrinsicReference,
InterpreterRuntimeError,
isRecord,
IteratorSymbol,
IteratorSymbols,
JsonMethodReference,
type MemberReference,
OptionalShortCircuit,
@@ -30,6 +33,7 @@ import {
ProgramThrow,
type ProgramNode,
SearchFunction,
SymbolNamespace,
type StatementResult,
supportedSyntaxMessage,
unsupportedSyntax,
@@ -166,7 +170,7 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
return false
}
throw new InterpreterRuntimeError(
"The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.",
"The right-hand side of 'instanceof' must be a supported constructor: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.",
node,
)
}
@@ -211,6 +215,12 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
}
}
type CustomIterator = {
iterator: SafeObject
next: unknown
asynchronous: boolean
}
export class Interpreter<R> {
private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -241,6 +251,7 @@ export class Interpreter<R> {
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("search", { mutable: false, value: new SearchFunction() })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("Symbol", { mutable: false, value: new SymbolNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
@@ -393,12 +404,9 @@ export class Interpreter<R> {
private createFunction(node: AstNode): CodeModeFunction {
if (node.generator === true) {
throw new InterpreterRuntimeError(
"Generator functions are not supported in CodeMode.",
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
throw new InterpreterRuntimeError("Generator functions are not supported.", node, "UnsupportedSyntax", [
supportedSyntaxMessage,
])
}
return new CodeModeFunction(
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
@@ -454,11 +462,7 @@ export class Interpreter<R> {
return Effect.gen(function* () {
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
if (containsOpaqueReference(discriminant)) {
throw new InterpreterRuntimeError(
"Switch discriminants must be data values in CodeMode.",
node,
"InvalidDataValue",
)
throw new InterpreterRuntimeError("Switch discriminants must be data values.", node, "InvalidDataValue")
}
self.scopes.push()
return yield* Effect.gen(function* () {
@@ -474,11 +478,7 @@ export class Interpreter<R> {
}
const candidate = yield* self.evaluateExpression(test)
if (containsOpaqueReference(candidate)) {
throw new InterpreterRuntimeError(
"Switch case values must be data values in CodeMode.",
test,
"InvalidDataValue",
)
throw new InterpreterRuntimeError("Switch case values must be data values.", test, "InvalidDataValue")
}
if (candidate === discriminant) {
selected = index
@@ -635,10 +635,7 @@ export class Interpreter<R> {
node: AstNode,
labels?: ReadonlySet<string>,
): Effect.Effect<StatementResult, unknown, R> {
if (getBoolean(node, "await")) {
throw new InterpreterRuntimeError("for await...of is not supported.", node)
}
const awaiting = getBoolean(node, "await")
const left = getNode(node, "left")
const declared = loopDeclaration(left, "for...of")
if (declared?.lexical) this.scopes.push()
@@ -650,8 +647,12 @@ export class Interpreter<R> {
const body = getNode(node, "body")
const iterable = spreadItems(right)
if (iterable === undefined) {
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined
if (iterable === undefined && iterator === undefined) {
throw new InterpreterRuntimeError(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`,
node,
)
}
let assignment: AstNode | undefined
@@ -668,8 +669,8 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
}
for (const value of iterable) {
const result = yield* Effect.gen(function* () {
const evaluateBody = (value: unknown) =>
Effect.gen(function* () {
if (declared) {
self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
@@ -686,22 +687,48 @@ export class Interpreter<R> {
),
)
if (iterable !== undefined) {
for (const value of iterable) {
const result = yield* evaluateBody(awaiting ? yield* self.awaitValue(value) : value)
if (result.kind === "return") return result
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
}
return { kind: "none" } satisfies StatementResult
}
if (iterator === undefined) throw new InterpreterRuntimeError("Custom iterator is unavailable.", node)
while (true) {
const step = yield* self.nextIteratorResult(iterator, node)
if (step.done) return { kind: "none" } satisfies StatementResult
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
if (!Exit.isSuccess(bodyExit)) {
// Process interruption must remain prompt; user cleanup cannot extend a timeout.
if (!Cause.hasInterruptsOnly(bodyExit.cause)) yield* Effect.exit(self.closeIterator(iterator, node))
return yield* Effect.failCause(bodyExit.cause)
}
const result = bodyExit.value
if (result.kind === "return") {
yield* self.closeIterator(iterator, node)
return result
}
if (result.kind === "break") {
yield* self.closeIterator(iterator, node)
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue") {
if (result.label !== undefined && !labels?.has(result.label)) return result
continue
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
yield* self.closeIterator(iterator, node)
return result
}
}
return { kind: "none" } satisfies StatementResult
}).pipe(
Effect.ensuring(
Effect.sync(() => {
@@ -711,6 +738,101 @@ export class Interpreter<R> {
)
}
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
}
private customIterator(value: unknown, node: AstNode) {
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
const asyncMethod = Reflect.get(value, AsyncIteratorSymbol)
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
if (method === undefined || method === null) return Effect.succeed(undefined)
const self = this
return Effect.map(
this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
(iterator) => {
const object = self.requireIteratorObject(iterator, "Iterator method result", node)
return {
iterator: object,
next: self.requireIteratorMethod(object.next, "Iterator next", node),
asynchronous: asyncMethod !== undefined && asyncMethod !== null,
}
},
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
const object = self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(iterator.next, [], node)),
"Iterator next() result",
node,
)
return { done: Boolean(object.done), value: object.value }
}
const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => {
const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
return { done: Boolean(object.done), value: object.value }
}),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) }
})
}
private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect<void, unknown, R> {
const close = iterator.iterator.return
if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow
const self = this
return Effect.gen(function* () {
const method = self.requireIteratorMethod(close, "Iterator return", node)
if (iterator.asynchronous) {
self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(method, [], node)),
"Iterator return() result",
node,
)
return
}
const called = yield* Effect.exit(self.invokeCallable(method, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
yield* self.awaitValue(captured.value)
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
if (isRecord(value) && !isRuntimeReference(value)) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
}
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
}
private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) {
return [...this.toolKeys(value.path)]
@@ -741,7 +863,7 @@ export class Interpreter<R> {
const keys = self.enumerableKeys(right)
if (keys === undefined) {
throw new InterpreterRuntimeError(
"for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
"for...in requires a plain object, array, or tools reference. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
node,
)
}
@@ -928,7 +1050,7 @@ export class Interpreter<R> {
)
}
const consumed = new Set<string>()
const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
@@ -937,15 +1059,19 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
for (const symbol of IteratorSymbols) {
if (!consumed.has(symbol) && Object.hasOwn(value, symbol))
Reflect.set(rest, symbol, Reflect.get(value, symbol))
}
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
continue
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(String(key))
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
@@ -1008,7 +1134,7 @@ export class Interpreter<R> {
}
const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>()
const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
if (property.type === "RestElement") {
@@ -1016,14 +1142,18 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
for (const symbol of IteratorSymbols) {
if (!consumed.has(symbol) && Object.hasOwn(source, symbol))
Reflect.set(rest, symbol, Reflect.get(source, symbol))
}
yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(String(key))
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
}
return
@@ -1050,7 +1180,7 @@ export class Interpreter<R> {
})
}
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
private destructuringPropertyKey(property: AstNode): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
}
@@ -1061,12 +1191,12 @@ export class Interpreter<R> {
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
}
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
if (!Array.isArray(source)) return source[String(key)]
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
if (!Array.isArray(source)) return Reflect.get(source, key)
if (key === "length") return source.length
if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
if (Object.hasOwn(source, key)) return Reflect.get(source, key)
if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined
}
@@ -1197,7 +1327,7 @@ export class Interpreter<R> {
if (first === null || first === undefined) return {}
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported in CodeMode; use the primitive value directly.`,
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
node,
)
}
@@ -1374,7 +1504,7 @@ export class Interpreter<R> {
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue")
throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue")
}
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
@@ -1465,7 +1595,7 @@ export class Interpreter<R> {
if (operator === "!") return !value
if (operator === "void") return undefined
if (containsOpaqueReference(value)) {
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
}
const operand =
value instanceof CodeModeDate
@@ -1580,11 +1710,7 @@ export class Interpreter<R> {
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: unknown): number => {
if (containsOpaqueReference(current)) {
throw new InterpreterRuntimeError(
`'${operator}' requires a data value in CodeMode.`,
argument,
"InvalidDataValue",
)
throw new InterpreterRuntimeError(`'${operator}' requires a data value.`, argument, "InvalidDataValue")
}
return coerceToNumber(current)
}
@@ -1700,6 +1826,12 @@ export class Interpreter<R> {
if (callable instanceof PromiseNamespace) {
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
}
if (callable instanceof SymbolNamespace) {
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
).as("TypeError")
}
if (callable instanceof PromiseCapabilityFunction) {
callable.settle(args[0])
return undefined
@@ -1707,7 +1839,7 @@ export class Interpreter<R> {
if (callable === undefined || callable === null) {
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee).as("TypeError")
}
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
throw new InterpreterRuntimeError("Only tools are callable here.", callee)
})
}
@@ -1723,8 +1855,7 @@ export class Interpreter<R> {
}
private invokeConsole(name: string, args: Array<unknown>, node: AstNode): undefined {
if (!consoleMethods.has(name))
throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node)
if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available.`, node)
this.logs.push(formatConsoleMessage(name, args))
return undefined
}
@@ -1739,10 +1870,7 @@ export class Interpreter<R> {
const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
const items = spreadItems(spread)
if (items === undefined)
throw new InterpreterRuntimeError(
"Spread arguments require an array, string, Map, or Set in CodeMode.",
argNode,
)
throw new InterpreterRuntimeError("Spread arguments require an array, string, Map, or Set.", argNode)
args.push(...items)
} else {
args.push(yield* self.evaluateExpression(argNode))
@@ -1808,17 +1936,15 @@ export class Interpreter<R> {
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError(
"Object spread requires a data object in CodeMode.",
property,
"InvalidDataValue",
)
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
}
for (const [key, value] of Object.entries(spread)) {
if (isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property)
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value
}
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol))
}
continue
}
@@ -1847,9 +1973,9 @@ export class Interpreter<R> {
}
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode)
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
}
objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
Reflect.set(objectValue, key, yield* self.evaluateExpression(valueNode))
}
return objectValue
@@ -1873,10 +1999,7 @@ export class Interpreter<R> {
const spread = yield* self.evaluateExpression(getNode(element, "argument"))
const items = spreadItems(spread)
if (items === undefined)
throw new InterpreterRuntimeError(
"Array spread requires an array, string, Map, or Set in CodeMode.",
element,
)
throw new InterpreterRuntimeError("Array spread requires an array, string, Map, or Set.", element)
values.push(...items)
} else {
values.push(yield* self.evaluateExpression(element))
@@ -1972,14 +2095,20 @@ export class Interpreter<R> {
return new PromiseMethodReference(key as PromiseMethodName)
}
throw new InterpreterRuntimeError(
`Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.any, Promise.resolve, and Promise.reject; consume promises with await.`,
`Promise.${String(key)} is not available. Available: Promise.all, Promise.allSettled, Promise.race, Promise.any, Promise.resolve, and Promise.reject; consume promises with await.`,
propertyNode,
)
}
if (objectValue instanceof SymbolNamespace) {
if (key === "asyncIterator") return new ComputedValue(AsyncIteratorSymbol)
if (key === "iterator") return new ComputedValue(IteratorSymbol)
return new ComputedValue(undefined)
}
if (objectValue instanceof GlobalNamespace) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
}
if (typeof key !== "string") return new ComputedValue(undefined)
if (objectValue.name === "Math" && mathConstants.has(key)) {
@@ -1998,7 +2127,7 @@ export class Interpreter<R> {
if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length)
const index = parseArrayIndex(key)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return new ComputedValue(objectValue[index])
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
@@ -2011,7 +2140,7 @@ export class Interpreter<R> {
if (objectValue instanceof CoercionFunction) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
}
if (typeof key !== "string") return new ComputedValue(undefined)
if (objectValue.name === "Number" && numberConstants.has(key)) {
@@ -2078,7 +2207,7 @@ export class Interpreter<R> {
if (isRuntimeReference(objectValue)) {
throw new InterpreterRuntimeError(
"CodeMode runtime references are opaque and do not expose properties.",
"Runtime references are opaque and do not expose properties.",
objectNode,
"InvalidDataValue",
)
@@ -2089,12 +2218,12 @@ export class Interpreter<R> {
}
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode)
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, propertyNode)
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = parseArrayIndex(key)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
@@ -2125,13 +2254,13 @@ export class Interpreter<R> {
if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return reference.target[reference.key]
return Reflect.get(reference.target, reference.key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
return Reflect.get(reference.target.url, reference.key)
}
return reference.target[String(reference.key)]
return Reflect.get(reference.target, reference.key)
})
}
@@ -2142,7 +2271,7 @@ export class Interpreter<R> {
private evaluateDeleteExpression(argument: AstNode): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument
if (target.type !== "MemberExpression") {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", argument)
throw new InterpreterRuntimeError("Only data fields may be deleted.", argument)
}
return Effect.map(this.getMemberReference(target, "delete"), (reference) => {
if (reference === OptionalShortCircuit) return true
@@ -2157,7 +2286,7 @@ export class Interpreter<R> {
reference instanceof JsonMethodReference ||
reference.target instanceof CodeModeURL
) {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
}
if (reference.target instanceof CodeModeRegExp) {
return Reflect.deleteProperty(reference.target.regex, reference.key)
@@ -2185,31 +2314,30 @@ export class Interpreter<R> {
reference instanceof GlobalMethodReference ||
reference instanceof JsonMethodReference
) {
throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node)
throw new InterpreterRuntimeError("Only data fields may be assigned.", node)
}
if (Array.isArray(reference.target)) {
if (reference.key === "length")
throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node)
if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned.", node)
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
}
}
const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
const key = reference.key
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
if (write) self.assignToReference(reference, key, next, node)
return result
})
}
private readReferenceValue(reference: MemberReference, key: number | string): unknown {
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[key]
return Reflect.get(reference.target.url, key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return (reference.target as Record<PropertyKey, unknown>)[key]
return Reflect.get(reference.target, key)
}
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) {
const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
@@ -2242,16 +2370,19 @@ export class Interpreter<R> {
return
}
const target = reference.target as SafeObject
const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node)
target[objectKey] = next
Reflect.set(target, key, next)
}
private toPropertyKey(value: unknown, node: AstNode): string | number {
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
if (typeof value === "string" || typeof value === "number") {
return value
}
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw new InterpreterRuntimeError("Property key must be a string or number.", node)
throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
}
}
+2 -2
View File
@@ -58,7 +58,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
seen.delete(value)
}
}
if (isRuntimeReference(value)) return "[CodeMode reference]"
if (isRuntimeReference(value)) return "[opaque reference]"
seen.add(value)
try {
if (Array.isArray(value)) {
@@ -74,7 +74,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
if (value === undefined) return "undefined"
if (containsOpaqueReference(value)) return "[CodeMode reference]"
if (containsOpaqueReference(value)) return "[opaque reference]"
const data = boundedData(value, "console.table argument")
const columns = consoleTableColumns(columnsArgument)
const rows = consoleTableRows(data, columns)
+2 -2
View File
@@ -59,7 +59,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
case "UTC":
return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))
default:
throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Date.${name} is not available.`, node)
}
}
@@ -170,7 +170,7 @@ export const invokeDateMethod = (
if (args.length < 3) return updateDate(value, hosted.setUTCFullYear(args[0], args[1]))
return updateDate(value, hosted.setUTCFullYear(args[0], args[1], args[2]))
default:
throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Date method '${name}' is not available.`, node)
}
}
+2 -2
View File
@@ -51,7 +51,7 @@ export const mathMethods = new Set([
])
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available.`, node)
if (name === "random") return Math.random()
if (name === "sumPrecise") {
const items = spreadItems(args[0])
@@ -151,5 +151,5 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
case "imul":
return Math.imul(a, b())
}
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Math.${name} is not available.`, node)
}
+2 -2
View File
@@ -45,7 +45,7 @@ export const invokeNumberMethod = (value: number, name: string, args: Array<unkn
result = value
break
default:
throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Number method '${name}' is not available.`, node)
}
return boundedData(result, `Number.${name} result`)
}
@@ -71,7 +71,7 @@ export const invokeNumberStatic = (name: string, args: Array<unknown>, node: Ast
case "parseFloat":
return parseFloat(coerceToString(value))
default:
throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Number.${name} is not available.`, node)
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+17 -5
View File
@@ -1,4 +1,10 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
@@ -30,7 +36,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return input as Record<string, unknown>
}
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = item
}
const addEntry = (out: Record<string, unknown>, key: unknown, item: unknown): void => {
@@ -46,10 +52,13 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
case "entries":
return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1]))
return Object.hasOwn(
requireObject(),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
)
case "is":
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values in CodeMode.", node, "InvalidDataValue")
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
}
return Object.is(args[0], args[1])
case "assign": {
@@ -64,6 +73,9 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
}
return out
}
@@ -94,5 +106,5 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return out
}
}
throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Object.${name} is not available.`, node)
}
+2 -2
View File
@@ -72,7 +72,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
}
export const invokeRegExpStatic = (name: string, args: Array<unknown>, node: AstNode): string => {
if (name !== "escape") throw new InterpreterRuntimeError(`RegExp.${name} is not available in CodeMode.`, node)
if (name !== "escape") throw new InterpreterRuntimeError(`RegExp.${name} is not available.`, node)
if (typeof args[0] !== "string") {
throw new InterpreterRuntimeError("RegExp.escape expects a string.", node).as("TypeError")
}
@@ -104,7 +104,7 @@ export const invokeRegExpMethod = (
case "toString":
return coerceToString(value)
default:
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available.`, node)
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const invokeStringStatic = (name: string, args: Array<unknown>, node: Ast
case "fromCodePoint":
return String.fromCodePoint(...codes)
default:
throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`String.${name} is not available.`, node)
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+2 -2
View File
@@ -69,7 +69,7 @@ export const urlArgument = (value: unknown, label: string): string =>
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available.`, node)
if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError")
const input = urlArgument(args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
@@ -83,7 +83,7 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`URL method '${name}' is not available.`, node)
}
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
import { CodeModeURL } from "../values.js"
+6 -6
View File
@@ -363,7 +363,7 @@ const termForms = (term: string): Array<string> => {
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
_tag: "CodeModeTool",
description: "Search available Code Mode tools",
description: "Search available tools",
input: SearchInput,
output: SearchOutput,
run: (input) =>
@@ -501,8 +501,8 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
empty
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
: complete
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below; surrounding agent tools are not available.",
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed below; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed or searchable below; surrounding agent tools are not available.",
...(empty
? []
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
@@ -533,8 +533,8 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
"## Rules",
"",
complete
? "- Only Code Mode tools listed here are available; surrounding agent tools are not implicitly exposed."
: "- Only Code Mode tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
? "- Only tools listed here are available; surrounding agent tools are not implicitly exposed."
: "- Only tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
@@ -553,7 +553,7 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.",
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]
+4 -4
View File
@@ -308,7 +308,7 @@ describe("CodeMode console capture", () => {
)
expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}'])
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[opaque reference],"ok":1}'])
})
test("console.table renders CodeMode value cells", async () => {
@@ -696,7 +696,7 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).toContain("surrounding agent tools are not available")
expect(instructions).toContain("Only Code Mode tools listed here are available")
expect(instructions).toContain("Only tools listed here are available")
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
// and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
@@ -718,7 +718,7 @@ describe("CodeMode public contract", () => {
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain("In the next execution, copy a returned path exactly")
expect(partial).toContain("Only Code Mode tools listed here or returned by the built-in `search` function")
expect(partial).toContain("Only tools listed here or returned by the built-in `search` function")
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
expect(partial).toContain("repeat the same search with `offset: next.offset`")
expect(partial).toContain(" limit?: number,\n offset?: number,")
@@ -739,7 +739,7 @@ describe("CodeMode public contract", () => {
expect(instructions).not.toContain("promise chaining")
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use Code Mode tools for external operations")
expect(instructions).toContain("Use tools for external operations")
expect(instructions).toContain(
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
)
@@ -0,0 +1,534 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/ticks-with-async-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js
* - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-is-null.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-non-callable.js
* - test/language/statements/for-await-of/iterator-close-throw-get-method-non-callable.js
*
* Copyright (C) 2019 André Bargull. All rights reserved.
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await execute(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("Test262 for-await-of adaptations", () => {
test("awaits promise and plain values from a synchronous array", async () => {
expect(
await value(`
const values = []
for await (const item of [Promise.resolve(1), 2, new Promise((resolve) => resolve(3))]) {
values.push(item)
}
return values
`),
).toEqual([1, 2, 3])
})
test("defers the body even for an already-resolved or plain value", async () => {
expect(
await value(`
const events = []
const before = Promise.resolve().then(() => events.push("before"))
for await (const item of [Promise.resolve(1), 2]) events.push("body " + item)
await before
return events
`),
).toEqual(["before", "body 1", "body 2"])
})
test("an awaited rejection exits through normal try/catch", async () => {
const result = await execute(`
const values = []
try {
for await (const item of [Promise.resolve(1), Promise.reject("stop"), Promise.resolve(3)]) {
values.push(item)
}
} catch (error) {
return [values, error]
}
return "missed"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toEqual([[1], "stop"])
expect(result.warnings ?? []).toEqual([])
})
test("destructures after resolving each yielded value", async () => {
expect(
await value(`
const values = []
for await (const [first, ...rest] of [Promise.resolve([1, 2, 3])]) {
values.push(first, rest)
}
return values
`),
).toEqual([1, [2, 3]])
})
test("supports assignment targets", async () => {
expect(
await value(`
let first
let rest
for await ([first, ...rest] of [Promise.resolve([1, 2, 3])]) {}
return [first, rest]
`),
).toEqual([1, [2, 3]])
})
test("preserves fresh lexical bindings per iteration", async () => {
expect(
await value(`
const reads = []
for await (const item of [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]) {
reads.push(() => item)
}
return reads.map((read) => read())
`),
).toEqual([1, 2, 3])
})
test("supports every existing collection iterable", async () => {
expect(
await value(`
const string = []
for await (const item of "ab") string.push(item)
const set = []
for await (const item of new Set([Promise.resolve(1), 2])) set.push(item)
const map = []
for await (const [key, item] of new Map([["a", 1], ["b", 2]])) map.push(key, item)
const params = []
for await (const [key, item] of new URLSearchParams("a=1&b=2")) params.push(key, item)
return { string, set, map, params }
`),
).toEqual({ string: ["a", "b"], set: [1, 2], map: ["a", 1, "b", 2], params: ["a", "1", "b", "2"] })
})
test("preserves labeled break and continue behavior", async () => {
expect(
await value(`
const values = []
outer: for await (const item of [1, 2, 3, 4]) {
if (item === 2) continue outer
if (item === 4) break outer
values.push(item)
}
return values
`),
).toEqual([1, 3])
})
test("drives a custom async iterator sequentially", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
async next() {
index += 1
if (index > 3) return { done: true }
return { done: false, value: index }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2, 3])
})
test("leaves async iterator values under the iterator's control", async () => {
expect(
await value(`
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) return [item instanceof Promise, await item]
`),
).toEqual([true, 1])
})
test("awaits synchronous results from an async iterator before the body", async () => {
expect(
await value(`
const events = ["pre"]
const ticks = Promise.resolve()
.then(() => events.push("tick 1"))
.then(() => events.push("tick 2"))
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) events.push(item instanceof Promise ? "loop" : "adopted")
events.push("post")
await ticks
return events
`),
).toEqual(["pre", "tick 1", "loop", "tick 2", "post"])
})
test("falls back to a custom synchronous iterator", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
index += 1
return index > 2 ? { done: true } : { done: false, value: Promise.resolve(index) }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("adopts terminal and close values from synchronous iterators", async () => {
expect(
await value(`
const terminal = {
[Symbol.iterator]: () => terminal,
next: () => ({ done: true, value: Promise.reject("terminal") }),
}
let terminalError
try {
for await (const item of terminal) {}
} catch (error) {
terminalError = error
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return: () => ({ done: true, value: Promise.reject("close") }),
}
let closeError
try {
for await (const item of closing) break
} catch (error) {
closeError = error
}
return [terminalError, closeError]
`),
).toEqual(["terminal", "close"])
})
test("captures the next method when acquiring the iterator", async () => {
expect(
await value(`
let count = 0
const next = () => {
count += 1
if (count === 1) iterator.next = () => ({ done: true })
return count > 2 ? { done: true } : { done: false, value: count }
}
const iterator = { [Symbol.iterator]: () => iterator, next }
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("captures synchronous iterator result fields before suspending", async () => {
expect(
await value(`
let count = 0
const result = { done: false, value: 1 }
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
count += 1
if (count > 1) return { done: true }
Promise.resolve().then(() => {
result.done = true
result.value = 2
})
return result
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1])
})
test("preserves the async close turn for a sync iterator without return", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => ({ done: false, value: 1 }),
}
for await (const item of iterator) {
Promise.resolve().then(() => events.push("reaction"))
break
}
events.push("after")
return events
`),
).toEqual(["reaction", "after"])
})
test("defers synchronous iterator protocol errors", async () => {
expect(
await value(`
const events = []
const throwing = {
[Symbol.iterator]: () => throwing,
next() {
Promise.resolve().then(() => events.push("next reaction"))
throw "next"
},
}
try {
for await (const item of throwing) {}
} catch (error) {
events.push("next catch")
}
const malformed = {
[Symbol.iterator]: () => malformed,
next() {
Promise.resolve().then(() => events.push("result reaction"))
return 1
},
}
try {
for await (const item of malformed) {}
} catch (error) {
events.push("result catch")
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return() {
Promise.resolve().then(() => events.push("return reaction"))
throw "return"
},
}
try {
for await (const item of closing) break
} catch (error) {
events.push("return catch")
}
return events
`),
).toEqual(["next reaction", "next catch", "result reaction", "result catch", "return reaction", "return catch"])
})
test("prefers Symbol.asyncIterator over Symbol.iterator", async () => {
expect(
await value(`
let done = false
const asyncIterator = {
next: async () => done ? { done: true } : (done = true, { done: false, value: "async" }),
}
const syncIterator = { next: () => ({ done: true }) }
const iterable = {
[Symbol.asyncIterator]: () => asyncIterator,
[Symbol.iterator]: () => syncIterator,
}
const values = []
for await (const item of iterable) values.push(item)
return values
`),
).toEqual(["async"])
})
test("closes a custom iterator on abrupt loop completion", async () => {
expect(
await value(`
let closed = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => (closed += 1, { done: true }),
}
for await (const item of iterator) break
try {
for await (const item of iterator) throw "stop"
} catch (error) {}
return closed
`),
).toBe(2)
})
test("treats a null iterator return method as absent", async () => {
expect(
await value(`
let count = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: null,
}
for await (const item of iterator) {
count += 1
break
}
return count
`),
).toBe(1)
})
test("a non-callable return method replaces break with TypeError", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("a body throw wins over a non-callable return method", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) throw "body"
} catch (error) {
return error
}
return "missed"
`),
).toBe("body")
})
test("rejects a primitive iterator return result", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => null,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("propagates iterator acquisition failures", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => { throw "acquire" },
}
try {
for await (const item of iterator) {}
} catch (error) {
return error
}
return "missed"
`),
).toBe("acquire")
})
test("rejects malformed iterator acquisition methods and results", async () => {
expect(
await value(`
const names = []
const invalid = [
{ [Symbol.asyncIterator]: true },
{ [Symbol.asyncIterator]: () => 1 },
{ [Symbol.asyncIterator]: () => ({ next: true }) },
]
for (const iterator of invalid) {
try {
for await (const item of iterator) {}
} catch (error) {
names.push(error.name)
}
}
return names
`),
).toEqual(["TypeError", "TypeError", "TypeError"])
})
test("preserves iterator protocol keys through object copies", async () => {
expect(
await value(`
let done = false
const iterable = {
plain: true,
[Symbol.asyncIterator]: () => iterable,
next: async () => done ? { done: true } : (done = true, { done: false, value: 1 }),
}
const spread = { ...iterable }
const { plain, ...rest } = iterable
const assigned = Object.assign({}, iterable)
const values = []
for await (const item of spread) values.push(item)
return [
values,
Object.hasOwn(spread, Symbol.asyncIterator),
Object.hasOwn(rest, Symbol.asyncIterator),
Object.hasOwn(assigned, Symbol.asyncIterator),
]
`),
).toEqual([[1], true, true, true])
})
test("rejects malformed iterator protocol results", async () => {
const result = await execute(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => 1,
}
for await (const item of iterator) {}
`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("Iterator next() result must be an object")
})
test("rejects objects without an iterator protocol method", async () => {
const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("or custom iterator value")
})
})
+12
View File
@@ -717,6 +717,18 @@ describe("destructuring assignment", () => {
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
})
test("excludes computed numeric keys from object rest", async () => {
expect(
await value(`
const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
let assigned
let assignmentRest
;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
return { declared, declarationRest, assigned, assignmentRest }
`),
).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
})
test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number")
+1 -25
View File
@@ -16,8 +16,6 @@
"opencode": "./bin/opencode"
},
"exports": {
"./effect/layer-node": "./src/effect/layer-node.ts",
"./effect/app-node": "./src/effect/app-node.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
@@ -43,11 +41,6 @@
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#runtime-import": {
"bun": "./src/runtime/import.bun.ts",
"node": "./src/runtime/import.node.ts",
"default": "./src/runtime/import.bun.ts"
},
"#process-lock-ffi": {
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
@@ -57,10 +50,7 @@
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@@ -97,52 +87,38 @@
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.9.4",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@openrouter/ai-sdk-provider": "2.9.0",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"zod": "catalog:"
},
"overrides": {
+1 -1
View File
@@ -1,6 +1,6 @@
export * as AgentV2 from "./agent"
import { makeLocationNode } from "./effect/app-node"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { EventV2 } from "./event"
+50 -10
View File
@@ -1,6 +1,7 @@
export * as AISDK from "./aisdk"
import { makeLocationNode } from "./effect/app-node"
import { AsyncLocalStorage } from "node:async_hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type {
JSONSchema7,
JSONValue,
@@ -29,7 +30,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth, Endpoint, type AnyRoute, type CallOptions } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
@@ -40,6 +41,12 @@ type SDK = any
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
type TransformRequest = NonNullable<CallOptions["transformRequest"]>
interface AISDKPrepared {
readonly call: LanguageModelV3CallOptions
readonly transformRequest?: TransformRequest
}
export interface SDKEvent {
readonly model: ModelV2.Info
@@ -103,7 +110,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: ModelV2.Info, pkg: string) {
function prepareOptions(model: ModelV2.Info, pkg: string, requests: AsyncLocalStorage<TransformRequest>) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
@@ -150,6 +157,21 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
}
}
const headers = new Headers(opts.headers)
const transformRequest = requests.getStore()
if (transformRequest) {
if (typeof opts.body !== "string") throw new Error("Session request hooks require a JSON request body")
const prepared = await Effect.runPromise(
transformRequest({
headers: Object.fromEntries(headers.entries()),
body: JSON.parse(opts.body),
}),
)
opts.headers = prepared.headers
opts.body = JSON.stringify(prepared.body)
}
if (!transformRequest) opts.headers = headers
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
...opts,
timeout: false,
@@ -194,6 +216,7 @@ export const locationLayer = Layer.effect(
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
const languages = new Map<string, LanguageModelV3>()
const sdks = new Map<string, SDK>()
const requests = new AsyncLocalStorage<TransformRequest>()
const functionIDs = new WeakMap<object, number>()
let nextFunctionID = 0
const cacheKey = (input: unknown) =>
@@ -267,7 +290,7 @@ export const locationLayer = Layer.effect(
})
const packageName = ProviderV2.packageName(model.package)
const options = prepareOptions(model, packageName)
const options = prepareOptions(model, packageName, requests)
const sdkKey = cacheKey({
providerID: model.providerID,
package: packageName,
@@ -292,7 +315,7 @@ export const locationLayer = Layer.effect(
return language
}),
model: Effect.fn("AISDK.model")(function* (model) {
return modelFromLanguage(model, yield* service.language(model))
return modelFromLanguage(model, yield* service.language(model), requests)
}),
})
return service
@@ -301,7 +324,11 @@ export const locationLayer = Layer.effect(
export const defaultLayer = locationLayer
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
function modelFromLanguage(
info: ModelV2.Info,
language: LanguageModelV3,
requests: AsyncLocalStorage<TransformRequest>,
) {
const packageName = ProviderV2.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
@@ -341,8 +368,13 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
},
with: () => route,
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
prepareTransport: (body, _request, options) =>
Effect.succeed({ call: body as LanguageModelV3CallOptions, transformRequest: options?.transformRequest }),
streamPrepared: (prepared) => {
const request = prepared as AISDKPrepared
if (!request.transformRequest) return streamLanguage(language, request.call)
return streamLanguage(language, request.call, requests, request.transformRequest)
},
}
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
}
@@ -533,13 +565,21 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
function streamLanguage(
language: LanguageModelV3,
options: LanguageModelV3CallOptions,
requests?: AsyncLocalStorage<TransformRequest>,
transformRequest?: TransformRequest,
) {
const state = { step: 0, toolNames: {} as Record<string, string> }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
try: () =>
requests && transformRequest
? requests.run(transformRequest, () => language.doStream(options))
: language.doStream(options),
catch: (error) => llmError("doStream", error),
}).pipe(
Effect.map((result) =>
+1 -1
View File
@@ -1,6 +1,6 @@
export * as Catalog from "./catalog"
import { makeLocationNode } from "./effect/app-node"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
+2 -2
View File
@@ -1,12 +1,12 @@
export * as CommandV2 from "./command"
import { makeLocationNode } from "./effect/app-node"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state"
import { MCP } from "./mcp/index"
import { EventV2 } from "./event"
import { AppProcess } from "./process"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config"
import { Location } from "./location"
+3 -3
View File
@@ -1,6 +1,6 @@
export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
@@ -11,8 +11,8 @@ import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { EventV2 } from "./event"
import { Watcher } from "./filesystem/watcher"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
+2 -2
View File
@@ -7,10 +7,10 @@ import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
import { Global } from "../../global"
import { Global } from "@opencode-ai/util/global"
import { PermissionV2 } from "../../permission"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/read"
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown"
+1 -1
View File
@@ -7,7 +7,7 @@ import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location"
export const Plugin = define({
+1 -1
View File
@@ -6,7 +6,7 @@ import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location"
export const Plugin = define({
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigVariable from "./variable"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "../fs-util"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { InvalidError } from "../v1/config/error"
type ParseSource =
@@ -1,10 +1,10 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { FSUtil } from "../fs-util"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Global } from "../global"
import { Global } from "@opencode-ai/util/global"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionExecution } from "../session/execution"
+1 -1
View File
@@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database"
import { makeGlobalNode } from "./effect/app-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql"
export const ID = Credential.ID
+6 -15
View File
@@ -3,11 +3,10 @@ export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { sqliteLayer } from "#sqlite"
import { Context, Effect, Layer, Schema } from "effect"
import { Global } from "../global"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { makeGlobalNode } from "../effect/app-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
@@ -40,20 +39,12 @@ const databaseLayer = Layer.effect(
}).pipe(Effect.orDie),
)
export function layer(options?: Options) {
export function layer(options: Options = { path: ":memory:" }) {
return Layer.suspend(() => {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
if (options?.path === ":memory:" || (options?.path && isAbsolute(options.path))) return provide(options.path)
if (options?.path) return provide(join(Global.Path.data, options.path))
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return provide(join(Global.Path.data, "opencode.db"))
return provide(
join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
)
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
return provide(join(Global.Path.data, filename))
})
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { buildLocationServiceMap } from "../location-services"
import { LocationServiceMap } from "../location-service-map"
import { LayerNode } from "./layer-node"
import { makeGlobalNode } from "./app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
let allReplacements = replacements
@@ -1,18 +1,13 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { FileSystem, Path } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { HttpClient } from "effect/unstable/http"
import { makeGlobalNode } from "./app-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
export const requestExecutor = makeGlobalNode({
service: RequestExecutor.Service,
layer: RequestExecutor.layer,
deps: [httpClient],
})
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
export * as LayerNodePlatform from "./app-node-platform"

Some files were not shown because too many files have changed in this diff Show More