chore: update merge branch with latest v2
This commit is contained in:
commit
8b37296036
1165 changed files with 15997 additions and 331387 deletions
|
|
@ -15,7 +15,7 @@
|
||||||
"dev:www": "bun run --cwd packages/www dev",
|
"dev:www": "bun run --cwd packages/www dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"lint": "oxlint",
|
"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",
|
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
|
||||||
Use `Image.generate` with an image model for direct asset generation:
|
Use `Image.generate` with an image model for direct asset generation:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Image } from "@opencode-ai/ai"
|
import { Image, ImageInput } from "@opencode-ai/ai"
|
||||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
const program = Effect.gen(function* () {
|
||||||
|
|
@ -49,6 +49,48 @@ const program = Effect.gen(function* () {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const response =
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "Combine these product photos into one studio scene",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(firstBytes, "image/png"),
|
||||||
|
ImageInput.url("https://example.com/second.webp"),
|
||||||
|
ImageInput.file("file_123"),
|
||||||
|
],
|
||||||
|
options,
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`ImageInput.fileUri(uri, mediaType)` represents provider file URIs such as Gemini Files. Raw strings are not
|
||||||
|
accepted as image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images`
|
||||||
|
uses text-to-image generation; a non-empty array selects the provider's edit behavior without enforcing provider
|
||||||
|
image-count limits locally. `images` is the only common image-editing field. OpenAI uses multipart for byte/data-URL
|
||||||
|
edits and its JSON reference body for URL or file-ID edits. Its provider-specific `options.mask` accepts an
|
||||||
|
`ImageInput` for inpainting:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey }).image("gpt-image-2"),
|
||||||
|
prompt,
|
||||||
|
images: [ImageInput.bytes(sourceBytes, "image/png")],
|
||||||
|
options: { mask: ImageInput.bytes(maskBytes, "image/png") },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The OpenAI adapter extracts this helper value into the edit request's native `mask` field rather than passing the
|
||||||
|
tagged `ImageInput` object through as an ordinary option. On multipart requests, `http.body` can override option
|
||||||
|
fields but not structural `model`, `prompt`, `image[]`, or `mask` fields, and the transport owns the multipart
|
||||||
|
`Content-Type` boundary. For JSON requests, `http.body` remains the final raw-native overlay. Gemini does not fetch
|
||||||
|
public HTTP URLs, and hosted Z.ai image generation does not accept image inputs. These cases fail with
|
||||||
|
`InvalidRequest` before network I/O.
|
||||||
|
|
||||||
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -81,6 +123,55 @@ yield *
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Google's current Gemini image models use the same direct API:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Google } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
|
const googleProgram = Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: Google.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
imageSize: "2K",
|
||||||
|
seed: 42,
|
||||||
|
thinkingLevel: "HIGH",
|
||||||
|
includeThoughts: true,
|
||||||
|
futureOption: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.images
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Google image options are request-scoped and inferred from the selected model. Known fields autocomplete while
|
||||||
|
future string values and arbitrary native Gemini `generationConfig` fields remain available. Native fields override
|
||||||
|
their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
|
||||||
|
`generateContent` without a local allowlist.
|
||||||
|
|
||||||
|
Z.ai image models infer open Z.ai-native options from the selected model:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
quality: "hd",
|
||||||
|
userID: "user-123",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
|
||||||
|
`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must
|
||||||
|
remain available.
|
||||||
|
|
||||||
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -181,7 +272,7 @@ const gateway = CloudflareAIGateway.configure({
|
||||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||||
```
|
```
|
||||||
|
|
||||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||||
|
|
||||||
### Package-like entrypoints
|
### Package-like entrypoints
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,44 @@ export const ImageModelSchema = Schema.declare((value): value is ImageModel => v
|
||||||
expected: "Image.Model",
|
expected: "Image.Model",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ImageBytesInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("bytes"),
|
||||||
|
data: Schema.Uint8Array,
|
||||||
|
mediaType: Schema.String,
|
||||||
|
})
|
||||||
|
const ImageUrlInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("url"),
|
||||||
|
url: Schema.String,
|
||||||
|
})
|
||||||
|
const ImageFileIDInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("file-id"),
|
||||||
|
id: Schema.String,
|
||||||
|
})
|
||||||
|
const ImageFileURIInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("file-uri"),
|
||||||
|
uri: Schema.String,
|
||||||
|
mediaType: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ImageInputSchema = Schema.Union([
|
||||||
|
ImageBytesInput,
|
||||||
|
ImageUrlInput,
|
||||||
|
ImageFileIDInput,
|
||||||
|
ImageFileURIInput,
|
||||||
|
]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>
|
||||||
|
|
||||||
|
export const ImageInput = {
|
||||||
|
bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
|
||||||
|
url: (url: string): ImageInput => ({ type: "url", url }),
|
||||||
|
file: (id: string): ImageInput => ({ type: "file-id", id }),
|
||||||
|
fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
|
||||||
|
} as const
|
||||||
|
|
||||||
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||||
model: ImageModelSchema,
|
model: ImageModelSchema,
|
||||||
prompt: Schema.String,
|
prompt: Schema.String,
|
||||||
|
images: Schema.optional(Schema.Array(ImageInputSchema)),
|
||||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
http: Schema.optional(HttpOptions),
|
http: Schema.optional(HttpOptions),
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export type {
|
||||||
Service as LLMClientService,
|
Service as LLMClientService,
|
||||||
} from "./route/client"
|
} from "./route/client"
|
||||||
export * from "./schema"
|
export * from "./schema"
|
||||||
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse } from "./image"
|
export { GeneratedImage, ImageInput, ImageInputSchema, ImageModel, ImageRequest, ImageResponse } from "./image"
|
||||||
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
|
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
|
||||||
export { Image } from "./image"
|
export { Image } from "./image"
|
||||||
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
||||||
|
|
|
||||||
314
packages/ai/src/protocols/google-images.ts
Normal file
314
packages/ai/src/protocols/google-images.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import {
|
||||||
|
GeneratedImage,
|
||||||
|
ImageModel,
|
||||||
|
ImageResponse,
|
||||||
|
type ImageInput,
|
||||||
|
type ImageRequestFor,
|
||||||
|
type ImageRoute,
|
||||||
|
} from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
LLMError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
type ProviderMetadata,
|
||||||
|
} from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
|
const ADAPTER = "google-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
|
export type GoogleImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type GoogleImageOptions = {
|
||||||
|
readonly aspectRatio?: GoogleImageString<
|
||||||
|
"1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "16:9" | "21:9"
|
||||||
|
>
|
||||||
|
readonly imageSize?: GoogleImageString<"1K" | "2K" | "4K">
|
||||||
|
readonly seed?: number
|
||||||
|
readonly thinkingLevel?: GoogleImageString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
|
||||||
|
readonly includeThoughts?: boolean
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
export type GoogleImageBody = Record<string, unknown> & {
|
||||||
|
readonly contents: ReadonlyArray<{
|
||||||
|
readonly role: "user"
|
||||||
|
readonly parts: ReadonlyArray<Record<string, unknown>>
|
||||||
|
}>
|
||||||
|
readonly generationConfig: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const GoogleUsage = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||||
|
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||||
|
promptTokenCount: Schema.optional(Schema.Number),
|
||||||
|
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||||
|
totalTokenCount: Schema.optional(Schema.Number),
|
||||||
|
promptTokensDetails: Schema.optional(Schema.Unknown),
|
||||||
|
candidatesTokensDetails: Schema.optional(Schema.Unknown),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
|
||||||
|
const GoogleImageResponse = Schema.Struct({
|
||||||
|
candidates: Schema.optional(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
index: Schema.optional(Schema.Number),
|
||||||
|
content: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
parts: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
text: Schema.optional(Schema.String),
|
||||||
|
thought: Schema.optional(Schema.Boolean),
|
||||||
|
thoughtSignature: Schema.optional(Schema.String),
|
||||||
|
inlineData: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
mimeType: Schema.String,
|
||||||
|
data: Schema.String,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
finishReason: Schema.optional(Schema.String),
|
||||||
|
finishMessage: Schema.optional(Schema.String),
|
||||||
|
safetyRatings: Schema.optional(Schema.Unknown),
|
||||||
|
citationMetadata: Schema.optional(Schema.Unknown),
|
||||||
|
groundingMetadata: Schema.optional(Schema.Unknown),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
usageMetadata: Schema.optional(GoogleUsage),
|
||||||
|
modelVersion: Schema.optional(Schema.String),
|
||||||
|
responseId: Schema.optional(Schema.String),
|
||||||
|
promptFeedback: Schema.optional(Schema.Unknown),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: GoogleImageOptions | undefined) => {
|
||||||
|
const { aspectRatio, imageSize, seed, thinkingLevel, includeThoughts, ...native } = options ?? {}
|
||||||
|
const image = {
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
}
|
||||||
|
const thinkingConfig = {
|
||||||
|
thinkingLevel,
|
||||||
|
includeThoughts,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
mergeJsonRecords(
|
||||||
|
{
|
||||||
|
responseModalities: ["IMAGE"],
|
||||||
|
imageConfig: Object.values(image).some((value) => value !== undefined) ? image : undefined,
|
||||||
|
seed,
|
||||||
|
thinkingConfig: Object.values(thinkingConfig).some((value) => value !== undefined) ? thinkingConfig : undefined,
|
||||||
|
},
|
||||||
|
native,
|
||||||
|
) ?? { responseModalities: ["IMAGE"] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
|
||||||
|
new LLMError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<GoogleImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("GoogleImages.generate")(function* (request: ImageRequestFor<GoogleImageOptions>, execute) {
|
||||||
|
const imageParts = yield* Effect.forEach(request.images ?? [], googleImagePart)
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{
|
||||||
|
contents: [{ role: "user", parts: [{ text: request.prompt }, ...imageParts] }],
|
||||||
|
generationConfig: nativeOptions(request.options),
|
||||||
|
},
|
||||||
|
http?.body,
|
||||||
|
) as GoogleImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(
|
||||||
|
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/models/${request.model.id}:generateContent`,
|
||||||
|
http?.query,
|
||||||
|
)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const candidates = decoded.candidates ?? []
|
||||||
|
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
|
||||||
|
index: candidate.index ?? candidateIndex,
|
||||||
|
finishReason: candidate.finishReason,
|
||||||
|
finishMessage: candidate.finishMessage,
|
||||||
|
safetyRatings: candidate.safetyRatings,
|
||||||
|
citationMetadata: candidate.citationMetadata,
|
||||||
|
groundingMetadata: candidate.groundingMetadata,
|
||||||
|
parts: (candidate.content?.parts ?? []).map((part) =>
|
||||||
|
part.inlineData === undefined
|
||||||
|
? {
|
||||||
|
type: "text",
|
||||||
|
text: part.text,
|
||||||
|
thought: part.thought,
|
||||||
|
thoughtSignature: part.thoughtSignature,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: part.inlineData.mimeType,
|
||||||
|
thought: part.thought,
|
||||||
|
thoughtSignature: part.thoughtSignature,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
const encoded = candidates.flatMap((candidate, candidateIndex) =>
|
||||||
|
(candidate.content?.parts ?? []).flatMap((part, partIndex) =>
|
||||||
|
part.inlineData === undefined || part.thought === true
|
||||||
|
? []
|
||||||
|
: [{ candidate, candidateIndex, partIndex, inlineData: part.inlineData }],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const images = yield* Effect.forEach(encoded, (item) =>
|
||||||
|
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
|
||||||
|
Effect.mapError(() =>
|
||||||
|
invalidOutput(
|
||||||
|
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: item.inlineData.mimeType,
|
||||||
|
data,
|
||||||
|
providerMetadata: {
|
||||||
|
google: {
|
||||||
|
candidateIndex: item.candidate.index ?? item.candidateIndex,
|
||||||
|
partIndex: item.partIndex,
|
||||||
|
finishReason: item.candidate.finishReason,
|
||||||
|
safetyRatings: item.candidate.safetyRatings,
|
||||||
|
citationMetadata: item.candidate.citationMetadata,
|
||||||
|
groundingMetadata: item.candidate.groundingMetadata,
|
||||||
|
thoughtSignature: item.candidate.content?.parts[item.partIndex]?.thoughtSignature,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (images.length === 0) {
|
||||||
|
const finishReasons = candidates.flatMap((candidate) =>
|
||||||
|
candidate.finishReason === undefined ? [] : [candidate.finishReason],
|
||||||
|
)
|
||||||
|
return yield* invalidOutput(
|
||||||
|
`Google Images returned no final images${
|
||||||
|
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
|
||||||
|
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
|
||||||
|
{
|
||||||
|
google: {
|
||||||
|
promptFeedback: decoded.promptFeedback,
|
||||||
|
candidates: candidateMetadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const usage = decoded.usageMetadata
|
||||||
|
const outputTokens =
|
||||||
|
usage?.candidatesTokenCount === undefined
|
||||||
|
? undefined
|
||||||
|
: usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage:
|
||||||
|
usage === undefined
|
||||||
|
? undefined
|
||||||
|
: new Usage({
|
||||||
|
inputTokens: usage.promptTokenCount,
|
||||||
|
outputTokens,
|
||||||
|
nonCachedInputTokens: ProviderShared.subtractTokens(
|
||||||
|
usage.promptTokenCount,
|
||||||
|
usage.cachedContentTokenCount,
|
||||||
|
),
|
||||||
|
cacheReadInputTokens: usage.cachedContentTokenCount,
|
||||||
|
reasoningTokens: usage.thoughtsTokenCount,
|
||||||
|
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||||
|
providerMetadata: { google: usage },
|
||||||
|
}),
|
||||||
|
providerMetadata: {
|
||||||
|
google: {
|
||||||
|
modelVersion: decoded.modelVersion,
|
||||||
|
responseId: decoded.responseId,
|
||||||
|
promptFeedback: decoded.promptFeedback,
|
||||||
|
candidates: candidateMetadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => {
|
||||||
|
if (image.type === "bytes")
|
||||||
|
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
|
||||||
|
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
|
||||||
|
if (image.type === "url")
|
||||||
|
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
|
||||||
|
Effect.flatMap((decoded) => {
|
||||||
|
if (decoded === undefined)
|
||||||
|
return Effect.fail(
|
||||||
|
ImageInputs.invalid(
|
||||||
|
ADAPTER,
|
||||||
|
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Effect.succeed({
|
||||||
|
inlineData: { mimeType: decoded.mediaType, data: Encoding.encodeBase64(decoded.data) },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(
|
||||||
|
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GoogleImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { Effect, Encoding, Schema } from "effect"
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { ImageModel, GeneratedImage, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
import {
|
||||||
|
ImageModel,
|
||||||
|
GeneratedImage,
|
||||||
|
ImageResponse,
|
||||||
|
type ImageInput,
|
||||||
|
type ImageRequestFor,
|
||||||
|
type ImageRoute,
|
||||||
|
} from "../image"
|
||||||
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
import {
|
import {
|
||||||
InvalidProviderOutputReason,
|
InvalidProviderOutputReason,
|
||||||
|
|
@ -11,15 +18,18 @@ import {
|
||||||
type HttpOptions,
|
type HttpOptions,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { ProviderShared } from "./shared"
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
import { OpenAIImage } from "./utils/openai-image"
|
import { OpenAIImage } from "./utils/openai-image"
|
||||||
|
|
||||||
const ADAPTER = "openai-images"
|
const ADAPTER = "openai-images"
|
||||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
export const PATH = "/images/generations"
|
export const PATH = "/images/generations"
|
||||||
|
export const EDIT_PATH = "/images/edits"
|
||||||
|
|
||||||
export type OpenAIImageString<Known extends string> = Known | (string & {})
|
export type OpenAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
export type OpenAIImageOptions = {
|
export type OpenAIImageOptions = {
|
||||||
|
readonly mask?: ImageInput
|
||||||
readonly n?: number
|
readonly n?: number
|
||||||
readonly size?: OpenAIImageString<
|
readonly size?: OpenAIImageString<
|
||||||
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
|
|
@ -64,9 +74,9 @@ export interface ModelInput {
|
||||||
readonly http?: HttpOptions
|
readonly http?: HttpOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
const nativeOptions = (options: Record<string, unknown> | undefined) => {
|
const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||||
if (!options) return undefined
|
if (!options) return undefined
|
||||||
const { outputFormat, outputCompression, ...native } = options
|
const { mask: _, outputFormat, outputCompression, ...native } = options
|
||||||
return {
|
return {
|
||||||
output_format: outputFormat,
|
output_format: outputFormat,
|
||||||
output_compression: outputCompression,
|
output_compression: outputCompression,
|
||||||
|
|
@ -92,14 +102,89 @@ export const model = (input: ModelInput) => {
|
||||||
const route: ImageRoute<OpenAIImageOptions> = {
|
const route: ImageRoute<OpenAIImageOptions> = {
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||||
|
const mask = request.options?.mask
|
||||||
|
if (mask !== undefined && (request.images?.length ?? 0) === 0)
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
|
||||||
const http = mergeHttpOptions(request.model.http, request.http)
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const sourceImages = request.images ?? []
|
||||||
|
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||||
|
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||||
|
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||||
|
return Effect.succeed(undefined)
|
||||||
|
})
|
||||||
|
const multipartMask =
|
||||||
|
mask === undefined
|
||||||
|
? undefined
|
||||||
|
: mask.type === "bytes"
|
||||||
|
? { data: mask.data, mediaType: mask.mediaType }
|
||||||
|
: mask.type === "url"
|
||||||
|
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
|
||||||
|
: undefined
|
||||||
|
const useMultipart =
|
||||||
|
sourceImages.length > 0 &&
|
||||||
|
multipartImages.every((image) => image !== undefined) &&
|
||||||
|
(mask === undefined || multipartMask !== undefined)
|
||||||
|
const path = sourceImages.length === 0 ? PATH : EDIT_PATH
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${path}`, http?.query)
|
||||||
|
|
||||||
|
if (useMultipart) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("model", request.model.id)
|
||||||
|
form.append("prompt", request.prompt)
|
||||||
|
Object.entries(mergeJsonRecords(nativeOptions(request.options), http?.body) ?? {}).forEach(([key, value]) => {
|
||||||
|
if (["model", "prompt", "image", "image[]", "images", "mask"].includes(key)) return
|
||||||
|
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
|
||||||
|
})
|
||||||
|
multipartImages.forEach((image, index) => {
|
||||||
|
if (image === undefined) return
|
||||||
|
form.append("image[]", imageBlob(image.data, image.mediaType), `image-${index}`)
|
||||||
|
})
|
||||||
|
if (multipartMask !== undefined)
|
||||||
|
form.append("mask", imageBlob(multipartMask.data, multipartMask.mediaType), "mask")
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: "[multipart/form-data]",
|
||||||
|
headers: Headers.remove(Headers.fromInput({ ...input.headers, ...http?.headers }), "content-type"),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyFormData(form)),
|
||||||
|
)
|
||||||
|
return yield* parseResponse(response, request.options, http?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const references = sourceImages.map((image) => {
|
||||||
|
if (image.type === "bytes") return { image_url: ImageInputs.dataUrl(image) }
|
||||||
|
if (image.type === "url") return { image_url: image.url }
|
||||||
|
if (image.type === "file-id") return { file_id: image.id }
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (references.some((image) => image === undefined))
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||||
|
const maskReference =
|
||||||
|
mask === undefined
|
||||||
|
? undefined
|
||||||
|
: mask.type === "bytes"
|
||||||
|
? { image_url: ImageInputs.dataUrl(mask) }
|
||||||
|
: mask.type === "url"
|
||||||
|
? { image_url: mask.url }
|
||||||
|
: mask.type === "file-id"
|
||||||
|
? { file_id: mask.id }
|
||||||
|
: undefined
|
||||||
|
if (mask !== undefined && maskReference === undefined)
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||||
const requestBody = mergeJsonRecords(
|
const requestBody = mergeJsonRecords(
|
||||||
{ model: request.model.id, prompt: request.prompt },
|
{
|
||||||
|
model: request.model.id,
|
||||||
|
prompt: request.prompt,
|
||||||
|
images: references.length === 0 ? undefined : references,
|
||||||
|
mask: maskReference,
|
||||||
|
},
|
||||||
nativeOptions(request.options),
|
nativeOptions(request.options),
|
||||||
http?.body,
|
http?.body,
|
||||||
) as OpenAIImageBody
|
) as OpenAIImageBody
|
||||||
const text = ProviderShared.encodeJson(requestBody)
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
|
||||||
const headers = yield* Auth.toEffect(input.auth)({
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
request,
|
request,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -113,58 +198,73 @@ export const model = (input: ModelInput) => {
|
||||||
HttpClientRequest.bodyText(text, "application/json"),
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const payload = yield* response.json.pipe(
|
return yield* parseResponse(response, request.options, http?.body)
|
||||||
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
|
||||||
)
|
|
||||||
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
|
||||||
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
|
||||||
)
|
|
||||||
const format =
|
|
||||||
decoded.output_format ?? (typeof requestBody.output_format === "string" ? requestBody.output_format : "png")
|
|
||||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
|
||||||
if (item.b64_json)
|
|
||||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
|
||||||
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
|
||||||
Effect.map(
|
|
||||||
(data) =>
|
|
||||||
new GeneratedImage({
|
|
||||||
mediaType: `image/${format}`,
|
|
||||||
data,
|
|
||||||
providerMetadata:
|
|
||||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (item.url)
|
|
||||||
return Effect.succeed(
|
|
||||||
new GeneratedImage({
|
|
||||||
mediaType: `image/${format}`,
|
|
||||||
data: item.url,
|
|
||||||
providerMetadata:
|
|
||||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
|
||||||
})
|
|
||||||
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
|
||||||
return new ImageResponse({
|
|
||||||
images,
|
|
||||||
usage:
|
|
||||||
decoded.usage === undefined
|
|
||||||
? undefined
|
|
||||||
: new Usage({
|
|
||||||
inputTokens: decoded.usage.input_tokens,
|
|
||||||
outputTokens: decoded.usage.output_tokens,
|
|
||||||
totalTokens: decoded.usage.total_tokens,
|
|
||||||
providerMetadata: { openai: decoded.usage },
|
|
||||||
}),
|
|
||||||
providerMetadata: { openai: { outputFormat: format } },
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
|
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||||
|
response: HttpClientResponse.HttpClientResponse,
|
||||||
|
options: OpenAIImageOptions | undefined,
|
||||||
|
overlay: Record<string, unknown> | undefined,
|
||||||
|
) {
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
|
||||||
|
const format =
|
||||||
|
decoded.output_format ?? (typeof requestBody?.output_format === "string" ? requestBody.output_format : "png")
|
||||||
|
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||||
|
if (item.b64_json)
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: `image/${format}`,
|
||||||
|
data,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (item.url)
|
||||||
|
return Effect.succeed(
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: `image/${format}`,
|
||||||
|
data: item.url,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||||
|
})
|
||||||
|
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage:
|
||||||
|
decoded.usage === undefined
|
||||||
|
? undefined
|
||||||
|
: new Usage({
|
||||||
|
inputTokens: decoded.usage.input_tokens,
|
||||||
|
outputTokens: decoded.usage.output_tokens,
|
||||||
|
totalTokens: decoded.usage.total_tokens,
|
||||||
|
providerMetadata: { openai: decoded.usage },
|
||||||
|
}),
|
||||||
|
providerMetadata: { openai: { outputFormat: format } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageBlob = (data: Uint8Array, mediaType: string) => {
|
||||||
|
const buffer = new ArrayBuffer(data.byteLength)
|
||||||
|
new Uint8Array(buffer).set(data)
|
||||||
|
return new Blob([buffer], { type: mediaType })
|
||||||
|
}
|
||||||
|
|
||||||
export const OpenAIImages = {
|
export const OpenAIImages = {
|
||||||
model,
|
model,
|
||||||
} as const
|
} as const
|
||||||
|
|
|
||||||
34
packages/ai/src/protocols/utils/image-input.ts
Normal file
34
packages/ai/src/protocols/utils/image-input.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Effect, Encoding } from "effect"
|
||||||
|
import type { ImageInput } from "../../image"
|
||||||
|
import { InvalidRequestReason, LLMError } from "../../schema"
|
||||||
|
|
||||||
|
const invalid = (module: string, message: string) =>
|
||||||
|
new LLMError({
|
||||||
|
module,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidRequestReason({ message }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
|
||||||
|
`data:${input.mediaType};base64,${Encoding.encodeBase64(input.data)}`
|
||||||
|
|
||||||
|
export const decodeDataUrl = (
|
||||||
|
url: string,
|
||||||
|
module: string,
|
||||||
|
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => {
|
||||||
|
if (!url.startsWith("data:")) return Effect.succeed(undefined)
|
||||||
|
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||||
|
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||||
|
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
|
||||||
|
Effect.map((data) => ({ mediaType: match[1], data })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invalidImageInput = invalid
|
||||||
|
|
||||||
|
export const ImageInputs = {
|
||||||
|
dataUrl,
|
||||||
|
decodeDataUrl,
|
||||||
|
invalid: invalidImageInput,
|
||||||
|
} as const
|
||||||
|
|
@ -11,10 +11,12 @@ import {
|
||||||
type HttpOptions,
|
type HttpOptions,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { ProviderShared, optionalNull } from "./shared"
|
import { ProviderShared, optionalNull } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
const ADAPTER = "xai-images"
|
const ADAPTER = "xai-images"
|
||||||
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||||
export const PATH = "/images/generations"
|
export const PATH = "/images/generations"
|
||||||
|
export const EDIT_PATH = "/images/edits"
|
||||||
|
|
||||||
export type XAIImageString<Known extends string> = Known | (string & {})
|
export type XAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
|
@ -111,13 +113,29 @@ export const model = (input: ModelInput) => {
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
|
generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
|
||||||
const http = mergeHttpOptions(request.model.http, request.http)
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const imageReferences = (request.images ?? []).map((image) => {
|
||||||
|
if (image.type === "bytes") return { url: ImageInputs.dataUrl(image), type: "image_url" as const }
|
||||||
|
if (image.type === "url") return { url: image.url, type: "image_url" as const }
|
||||||
|
if (image.type === "file-id") return { file_id: image.id }
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (imageReferences.some((image) => image === undefined))
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||||
const requestBody = mergeJsonRecords(
|
const requestBody = mergeJsonRecords(
|
||||||
{ model: request.model.id, prompt: request.prompt },
|
{
|
||||||
|
model: request.model.id,
|
||||||
|
prompt: request.prompt,
|
||||||
|
image: imageReferences.length === 1 ? imageReferences[0] : undefined,
|
||||||
|
images: imageReferences.length > 1 ? imageReferences : undefined,
|
||||||
|
},
|
||||||
nativeOptions(request.options),
|
nativeOptions(request.options),
|
||||||
http?.body,
|
http?.body,
|
||||||
) as XAIImageBody
|
) as XAIImageBody
|
||||||
const text = ProviderShared.encodeJson(requestBody)
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
const url = applyQuery(
|
||||||
|
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${imageReferences.length === 0 ? PATH : EDIT_PATH}`,
|
||||||
|
http?.query,
|
||||||
|
)
|
||||||
const headers = yield* Auth.toEffect(input.auth)({
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
request,
|
request,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
132
packages/ai/src/protocols/zai-images.ts
Normal file
132
packages/ai/src/protocols/zai-images.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
|
const ADAPTER = "zai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
|
||||||
|
export type ZAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type ZAIImageOptions = {
|
||||||
|
readonly size?: ZAIImageString<
|
||||||
|
"1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
|
||||||
|
>
|
||||||
|
readonly quality?: ZAIImageString<"hd" | "standard">
|
||||||
|
readonly userID?: string
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
type ZAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZAIImageResponse = Schema.Struct({
|
||||||
|
created: Schema.optional(Schema.Int),
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
|
request_id: Schema.optional(Schema.String),
|
||||||
|
data: Schema.Array(Schema.Struct({ url: Schema.String })),
|
||||||
|
content_filter: Schema.optional(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
role: Schema.optional(Schema.String),
|
||||||
|
level: Schema.optional(Schema.Number),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: ZAIImageOptions | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { userID, ...native } = options
|
||||||
|
return {
|
||||||
|
user_id: userID,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new LLMError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<ZAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
|
||||||
|
if ((request.images?.length ?? 0) > 0)
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{ model: request.model.id, prompt: request.prompt },
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as ZAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
|
||||||
|
return new ImageResponse({
|
||||||
|
images: decoded.data.map(
|
||||||
|
(item) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
data: item.url,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
providerMetadata: {
|
||||||
|
zai: {
|
||||||
|
created: decoded.created,
|
||||||
|
id: decoded.id,
|
||||||
|
requestID: decoded.request_id,
|
||||||
|
contentFilter: decoded.content_filter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<ZAIImageOptions>({ id: input.id, provider: "zai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
|
|
@ -2,14 +2,20 @@ import type { RouteDefaultsInput } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import type { ProviderAuthOption } from "../route/auth-options"
|
import type { ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { ProviderPackage } from "../provider-package"
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema"
|
||||||
import * as Gemini from "../protocols/gemini"
|
import { Gemini } from "../protocols/gemini"
|
||||||
|
import { GoogleImages } from "../protocols/google-images"
|
||||||
|
|
||||||
|
export type { GoogleImageOptions } from "../protocols/google-images"
|
||||||
|
|
||||||
export const id = ProviderID.make("google")
|
export const id = ProviderID.make("google")
|
||||||
|
|
||||||
export const routes = [Gemini.route]
|
export const routes = [Gemini.route]
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
export type Config = RouteDefaultsInput &
|
||||||
|
ProviderAuthOption<"optional"> & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
|
|
@ -31,9 +37,18 @@ const configuredRoute = (input: Config) => {
|
||||||
|
|
||||||
export const configure = (input: Config = {}) => {
|
export const configure = (input: Config = {}) => {
|
||||||
const route = configuredRoute(input)
|
const route = configuredRoute(input)
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
GoogleImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||||
|
image,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -48,3 +63,5 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
||||||
limits: settings.limits,
|
limits: settings.limits,
|
||||||
providerOptions: settings.providerOptions,
|
providerOptions: settings.providerOptions,
|
||||||
}).model(modelID)
|
}).model(modelID)
|
||||||
|
|
||||||
|
export const image = provider.image
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,4 @@ export * as OpenAICompatible from "./openai-compatible"
|
||||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||||
export * as OpenRouter from "./openrouter"
|
export * as OpenRouter from "./openrouter"
|
||||||
export * as XAI from "./xai"
|
export * as XAI from "./xai"
|
||||||
|
export * as ZAI from "./zai"
|
||||||
|
|
|
||||||
35
packages/ai/src/providers/zai.ts
Normal file
35
packages/ai/src/providers/zai.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { ZAIImages } from "../protocols/zai-images"
|
||||||
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
|
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||||
|
|
||||||
|
export const id = ProviderID.make("zai")
|
||||||
|
|
||||||
|
export type Config = ProviderAuthOption<"optional"> & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions.Input
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ZAIImageOptions } from "../protocols/zai-images"
|
||||||
|
|
||||||
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
|
||||||
|
|
||||||
|
export const configure = (input: Config = {}) => {
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
ZAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
image,
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provider = configure()
|
||||||
|
export const image = provider.image
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { LLM, LLMClient, Provider } from "@opencode-ai/ai"
|
import { ImageInput, LLM, LLMClient, Provider } from "@opencode-ai/ai"
|
||||||
import { Route, Protocol } from "@opencode-ai/ai/route"
|
import { Route, Protocol } from "@opencode-ai/ai/route"
|
||||||
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,12 +11,7 @@ import {
|
||||||
XAI,
|
XAI,
|
||||||
} from "@opencode-ai/ai/providers"
|
} from "@opencode-ai/ai/providers"
|
||||||
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
|
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
|
||||||
import {
|
import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||||
OpenAIChat,
|
|
||||||
OpenAICompatibleChat,
|
|
||||||
OpenAICompatibleResponses,
|
|
||||||
OpenAIResponses,
|
|
||||||
} from "@opencode-ai/ai/protocols"
|
|
||||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||||
|
|
||||||
describe("public exports", () => {
|
describe("public exports", () => {
|
||||||
|
|
@ -24,6 +19,7 @@ describe("public exports", () => {
|
||||||
expect(LLM.request).toBeFunction()
|
expect(LLM.request).toBeFunction()
|
||||||
expect(LLMClient.Service).toBeFunction()
|
expect(LLMClient.Service).toBeFunction()
|
||||||
expect(LLMClient.layer).toBeDefined()
|
expect(LLMClient.layer).toBeDefined()
|
||||||
|
expect(ImageInput.bytes).toBeFunction()
|
||||||
expect(Provider.make).toBeFunction()
|
expect(Provider.make).toBeFunction()
|
||||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
BIN
packages/ai/test/fixtures/images/edit-source.jpg
vendored
Normal file
BIN
packages/ai/test/fixtures/images/edit-source.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 896 B |
28
packages/ai/test/fixtures/recordings/google-images/edits-an-image.json
vendored
Normal file
28
packages/ai/test/fixtures/recordings/google-images/edits-an-image.json
vendored
Normal file
File diff suppressed because one or more lines are too long
32
packages/ai/test/fixtures/recordings/google-images/generates-an-image.json
vendored
Normal file
32
packages/ai/test/fixtures/recordings/google-images/generates-an-image.json
vendored
Normal file
File diff suppressed because one or more lines are too long
28
packages/ai/test/fixtures/recordings/openai-images/edits-an-image.json
vendored
Normal file
28
packages/ai/test/fixtures/recordings/openai-images/edits-an-image.json
vendored
Normal file
File diff suppressed because one or more lines are too long
28
packages/ai/test/fixtures/recordings/xai-images/edits-an-image.json
vendored
Normal file
28
packages/ai/test/fixtures/recordings/xai-images/edits-an-image.json
vendored
Normal file
File diff suppressed because one or more lines are too long
28
packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json
vendored
Normal file
28
packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": ["prefix:zai-images", "provider:zai", "protocol:zai-images"],
|
||||||
|
"name": "zai-images/generates-an-image",
|
||||||
|
"recordedAt": "2026-07-19T16:03:55.761Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.z.ai/api/paas/v4/images/generations",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"cogview-4-250304\",\"prompt\":\"A simple flat red circle centered on a plain white background.\",\"size\":\"1024x1024\",\"quality\":\"standard\",\"user_id\":\"opencode-image-test\"}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json; charset=UTF-8"
|
||||||
|
},
|
||||||
|
"body": "{\"created\":1784477028,\"data\":[{\"url\":\"https://mfile.z.ai/1784477035500-43574eab2b6e402da9063d6ac22dfefb.png?ufileattname=202607200003482062c3bba9b04f7d_watermark.png\"}],\"id\":\"202607200003482062c3bba9b04f7d\",\"request_id\":\"202607200003482062c3bba9b04f7d\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Image, ImageClient } from "../src"
|
import { Image, ImageClient, ImageInput } from "../src"
|
||||||
import { OpenAI } from "../src/providers"
|
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
import { dynamicResponse } from "./lib/http"
|
import { dynamicResponse } from "./lib/http"
|
||||||
|
|
||||||
|
|
@ -124,4 +124,455 @@ describe("Image", () => {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("routes OpenAI byte inputs and masks through multipart edits", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||||
|
ImageInput.url("data:image/jpeg;base64,BAUG"),
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
mask: ImageInput.bytes(Uint8Array.from([7, 8, 9]), "image/png"),
|
||||||
|
quality: "high",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" },
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe("https://api.openai.test/v1/images/edits")
|
||||||
|
expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=")
|
||||||
|
expect(input.text).toContain('name="model"\r\n\r\nfuture-model')
|
||||||
|
expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images')
|
||||||
|
expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2)
|
||||||
|
expect(input.text).toContain('name="mask"')
|
||||||
|
expect(input.text).toContain('name="quality"\r\n\r\nlow')
|
||||||
|
expect(input.text).not.toContain("corrupt")
|
||||||
|
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("routes OpenAI URL and file inputs through JSON edits", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [ImageInput.url("https://example.test/source.png"), ImageInput.file("file_123")],
|
||||||
|
options: { mask: ImageInput.file("file_mask") },
|
||||||
|
http: { body: { future_option: true } },
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-model",
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }],
|
||||||
|
mask: { file_id: "file_mask" },
|
||||||
|
future_option: true,
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("routes ordered xAI image inputs through JSON edits", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||||
|
ImageInput.url("https://example.test/source.jpg"),
|
||||||
|
ImageInput.file("file_123"),
|
||||||
|
],
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-model",
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [
|
||||||
|
{ url: "data:image/png;base64,AQID", type: "image_url" },
|
||||||
|
{ url: "https://example.test/source.jpg", type: "image_url" },
|
||||||
|
{ file_id: "file_123" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses xAI's singular image field for one input", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
|
||||||
|
prompt: "Edit this image",
|
||||||
|
images: [ImageInput.file("file_123")],
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-model",
|
||||||
|
prompt: "Edit this image",
|
||||||
|
image: { file_id: "file_123" },
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lowers ordered Google image inputs into generateContent parts", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"),
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||||
|
ImageInput.url("data:image/jpeg;base64,BAUG"),
|
||||||
|
ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"),
|
||||||
|
],
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text).contents[0].parts).toEqual([
|
||||||
|
{ text: "Combine these images" },
|
||||||
|
{ inlineData: { mimeType: "image/png", data: "AQID" } },
|
||||||
|
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
||||||
|
{
|
||||||
|
fileData: {
|
||||||
|
mimeType: "image/webp",
|
||||||
|
fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }],
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects unsupported provider inputs before sending", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cases = [
|
||||||
|
Image.generate({
|
||||||
|
model: Google.configure({ apiKey: "test" }).image("model"),
|
||||||
|
prompt: "edit",
|
||||||
|
images: [ImageInput.url("https://example.test/image.png")],
|
||||||
|
}),
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
||||||
|
prompt: "edit",
|
||||||
|
images: [ImageInput.bytes(Uint8Array.from([1]), "image/png")],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
yield* Effect.forEach(cases, (program) =>
|
||||||
|
program.pipe(
|
||||||
|
Effect.flip,
|
||||||
|
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(dynamicResponse(() => Effect.die("unsupported input reached the network"))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("generates images through the Google generateContent API", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: Google.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://generativelanguage.test/v1beta/",
|
||||||
|
headers: { "x-default": "yes" },
|
||||||
|
http: { body: { labels: { deployment: "test" } }, query: { api: "v1" } },
|
||||||
|
}).image("any-model-id"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
imageSize: "2K",
|
||||||
|
seed: 42,
|
||||||
|
thinkingLevel: "HIGH",
|
||||||
|
includeThoughts: true,
|
||||||
|
futureOption: true,
|
||||||
|
imageConfig: { aspectRatio: "4:3", nativeImageOption: true },
|
||||||
|
thinkingConfig: { thinkingLevel: "LOW", nativeThinkingOption: true },
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
body: {
|
||||||
|
safetySettings: [],
|
||||||
|
generationConfig: {
|
||||||
|
imageConfig: { aspectRatio: "3:2", httpImageOption: true },
|
||||||
|
thinkingConfig: { includeThoughts: false, httpThinkingOption: true },
|
||||||
|
futureOption: "http",
|
||||||
|
httpOption: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: { "x-request": "yes" },
|
||||||
|
query: { trace: "1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(3)
|
||||||
|
expect(response.images.map((image) => image.data)).toEqual([
|
||||||
|
Uint8Array.from([1, 2, 3]),
|
||||||
|
Uint8Array.from([4, 5, 6]),
|
||||||
|
Uint8Array.from([7, 8, 9]),
|
||||||
|
])
|
||||||
|
expect(response.images.map((image) => image.mediaType)).toEqual(["image/png", "image/jpeg", "image/webp"])
|
||||||
|
expect(response.images[0].providerMetadata).toMatchObject({ google: { thoughtSignature: "signature-1" } })
|
||||||
|
expect(response.images[1].providerMetadata).toMatchObject({
|
||||||
|
google: { candidateIndex: 0, partIndex: 3, finishReason: "STOP" },
|
||||||
|
})
|
||||||
|
expect(response.images[2].providerMetadata).toMatchObject({ google: { candidateIndex: 7, partIndex: 0 } })
|
||||||
|
expect(response.usage?.inputTokens).toBe(5)
|
||||||
|
expect(response.usage?.outputTokens).toBe(10)
|
||||||
|
expect(response.usage?.reasoningTokens).toBe(3)
|
||||||
|
expect(response.usage?.providerMetadata).toMatchObject({ google: { serviceTier: "STANDARD" } })
|
||||||
|
expect(response.providerMetadata).toEqual({
|
||||||
|
google: {
|
||||||
|
modelVersion: "gemini-3.1-flash-image",
|
||||||
|
responseId: "response-1",
|
||||||
|
promptFeedback: undefined,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: "STOP",
|
||||||
|
finishMessage: undefined,
|
||||||
|
safetyRatings: [{ category: "safe" }],
|
||||||
|
citationMetadata: undefined,
|
||||||
|
groundingMetadata: undefined,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: "image/png",
|
||||||
|
thought: undefined,
|
||||||
|
thoughtSignature: "signature-1",
|
||||||
|
},
|
||||||
|
{ type: "text", text: "planning", thought: true, thoughtSignature: "text-signature" },
|
||||||
|
{
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: "image/png",
|
||||||
|
thought: true,
|
||||||
|
thoughtSignature: "draft-signature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: "image/jpeg",
|
||||||
|
thought: undefined,
|
||||||
|
thoughtSignature: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 7,
|
||||||
|
finishReason: undefined,
|
||||||
|
finishMessage: undefined,
|
||||||
|
safetyRatings: undefined,
|
||||||
|
citationMetadata: undefined,
|
||||||
|
groundingMetadata: undefined,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: "image/webp",
|
||||||
|
thought: undefined,
|
||||||
|
thoughtSignature: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe(
|
||||||
|
"https://generativelanguage.test/v1beta/models/any-model-id:generateContent?api=v1&trace=1",
|
||||||
|
)
|
||||||
|
expect(request.headers.get("x-goog-api-key")).toBe("test")
|
||||||
|
expect(request.headers.get("x-default")).toBe("yes")
|
||||||
|
expect(request.headers.get("x-request")).toBe("yes")
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
contents: [{ role: "user", parts: [{ text: "A robot tending a rooftop garden" }] }],
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ["IMAGE"],
|
||||||
|
imageConfig: {
|
||||||
|
aspectRatio: "3:2",
|
||||||
|
imageSize: "2K",
|
||||||
|
nativeImageOption: true,
|
||||||
|
httpImageOption: true,
|
||||||
|
},
|
||||||
|
seed: 42,
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: "LOW",
|
||||||
|
includeThoughts: false,
|
||||||
|
nativeThinkingOption: true,
|
||||||
|
httpThinkingOption: true,
|
||||||
|
},
|
||||||
|
futureOption: "http",
|
||||||
|
httpOption: true,
|
||||||
|
},
|
||||||
|
labels: { deployment: "test" },
|
||||||
|
safetySettings: [],
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
inlineData: { mimeType: "image/png", data: "AQID" },
|
||||||
|
thoughtSignature: "signature-1",
|
||||||
|
},
|
||||||
|
{ text: "planning", thought: true, thoughtSignature: "text-signature" },
|
||||||
|
{
|
||||||
|
inlineData: { mimeType: "image/png", data: "CgsM" },
|
||||||
|
thought: true,
|
||||||
|
thoughtSignature: "draft-signature",
|
||||||
|
},
|
||||||
|
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: "STOP",
|
||||||
|
safetyRatings: [{ category: "safe" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 7,
|
||||||
|
content: { parts: [{ inlineData: { mimeType: "image/webp", data: "BwgJ" } }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 5,
|
||||||
|
candidatesTokenCount: 7,
|
||||||
|
thoughtsTokenCount: 3,
|
||||||
|
totalTokenCount: 15,
|
||||||
|
serviceTier: "STANDARD",
|
||||||
|
},
|
||||||
|
modelVersion: "gemini-3.1-flash-image",
|
||||||
|
responseId: "response-1",
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("includes Google diagnostics when no final image is returned", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image(
|
||||||
|
"gemini-3.1-flash-image",
|
||||||
|
),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
}).pipe(
|
||||||
|
Effect.flip,
|
||||||
|
Effect.tap((error) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||||
|
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||||
|
expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
|
||||||
|
expect(error.reason.providerMetadata).toEqual({
|
||||||
|
google: {
|
||||||
|
promptFeedback: { blockReason: "SAFETY" },
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: "IMAGE_SAFETY",
|
||||||
|
finishMessage: "The generated image was blocked by safety filters.",
|
||||||
|
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||||
|
citationMetadata: undefined,
|
||||||
|
groundingMetadata: undefined,
|
||||||
|
parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.succeed(
|
||||||
|
input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: "blocked", thought: false }] },
|
||||||
|
finishReason: "IMAGE_SAFETY",
|
||||||
|
finishMessage: "The generated image was blocked by safety filters.",
|
||||||
|
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
promptFeedback: { blockReason: "SAFETY" },
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import {
|
import {
|
||||||
Image,
|
Image,
|
||||||
|
ImageInput,
|
||||||
ImageModel,
|
ImageModel,
|
||||||
type ImageModelOptions,
|
type ImageModelOptions,
|
||||||
type ImageOptions,
|
type ImageOptions,
|
||||||
type ImageRequestFor,
|
type ImageRequestFor,
|
||||||
type ImageRoute,
|
type ImageRoute,
|
||||||
} from "../src"
|
} from "../src"
|
||||||
import { OpenAI, XAI } from "../src/providers"
|
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||||
|
|
||||||
type GoogleLikeOptions = {
|
type GoogleLikeOptions = {
|
||||||
readonly aspectRatio?: "1:1" | "16:9"
|
readonly aspectRatio?: "1:1" | "16:9"
|
||||||
|
|
@ -22,9 +23,41 @@ void invalidGoogleOptions
|
||||||
Image.generate({
|
Image.generate({
|
||||||
model: google,
|
model: google,
|
||||||
prompt: "A lighthouse",
|
prompt: "A lighthouse",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||||
|
ImageInput.url("data:image/jpeg;base64,AQID"),
|
||||||
|
ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/example", "image/webp"),
|
||||||
|
],
|
||||||
options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true },
|
options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const googleProvider = Google.configure({ apiKey: "test" }).image("any-model-id")
|
||||||
|
Image.generate({
|
||||||
|
model: googleProvider,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: {
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
imageSize: "2K",
|
||||||
|
seed: 42,
|
||||||
|
thinkingLevel: "HIGH",
|
||||||
|
includeThoughts: true,
|
||||||
|
futureOption: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Image.generate({
|
||||||
|
model: googleProvider,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspectRatio: "future-ratio", imageSize: "8K", thinkingLevel: "FUTURE" },
|
||||||
|
})
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
Google.configure({ image: { providerOptions: { imageSize: "2K" } } })
|
||||||
|
// @ts-expect-error Known Google string options retain their value kind.
|
||||||
|
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { imageSize: 2 } })
|
||||||
|
// @ts-expect-error Known Google numeric options retain their value kind.
|
||||||
|
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { seed: "42" } })
|
||||||
|
// @ts-expect-error Known Google boolean options retain their value kind.
|
||||||
|
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { includeThoughts: "yes" } })
|
||||||
|
|
||||||
const openai = OpenAI.image("gpt-image-2")
|
const openai = OpenAI.image("gpt-image-2")
|
||||||
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
OpenAI.configure({ image: { options: { quality: "medium" } } })
|
OpenAI.configure({ image: { options: { quality: "medium" } } })
|
||||||
|
|
@ -33,7 +66,14 @@ void futureOpenAIOptions
|
||||||
Image.generate({
|
Image.generate({
|
||||||
model: openai,
|
model: openai,
|
||||||
prompt: "A lighthouse",
|
prompt: "A lighthouse",
|
||||||
options: { quality: "hd", outputFormat: "webp", size: "2048x2048", future_option: true },
|
images: [ImageInput.url("https://example.com/source.png"), ImageInput.file("file_123")],
|
||||||
|
options: {
|
||||||
|
mask: ImageInput.bytes(Uint8Array.from([1]), "image/png"),
|
||||||
|
quality: "hd",
|
||||||
|
outputFormat: "webp",
|
||||||
|
size: "2048x2048",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "future-quality", size: "256x256" } })
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "future-quality", size: "256x256" } })
|
||||||
Image.generate({ model: openai, prompt: "A lighthouse", options: { size: "1792x1024" } })
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { size: "1792x1024" } })
|
||||||
|
|
@ -54,6 +94,7 @@ XAI.configure({ image: { options: { resolution: "1k" } } })
|
||||||
Image.generate({
|
Image.generate({
|
||||||
model: xai,
|
model: xai,
|
||||||
prompt: "A lighthouse",
|
prompt: "A lighthouse",
|
||||||
|
images: [ImageInput.url("data:image/png;base64,AQID"), ImageInput.file("file_123")],
|
||||||
options: {
|
options: {
|
||||||
n: 2,
|
n: 2,
|
||||||
aspectRatio: "future-ratio",
|
aspectRatio: "future-ratio",
|
||||||
|
|
@ -72,8 +113,31 @@ Image.generate({ model: xai, prompt: "A lighthouse", options: { n: "2" } })
|
||||||
// @ts-expect-error Known xAI string options retain their value kind.
|
// @ts-expect-error Known xAI string options retain their value kind.
|
||||||
Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
|
Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
|
||||||
|
|
||||||
|
const zai = ZAI.configure({ apiKey: "test" }).image("any-model-id")
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
ZAI.configure({ image: { options: { quality: "hd" } } })
|
||||||
|
Image.generate({
|
||||||
|
model: zai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { quality: "future-quality", userID: "user-123", future_option: true },
|
||||||
|
})
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { user_id: "raw-user" } })
|
||||||
|
// @ts-expect-error Known Z.ai string options retain their value kind.
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { quality: 1 } })
|
||||||
|
// @ts-expect-error Known Z.ai user IDs retain their value kind.
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { userID: 1 } })
|
||||||
|
|
||||||
declare const generic: ImageModel<ImageOptions>
|
declare const generic: ImageModel<ImageOptions>
|
||||||
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
|
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
|
||||||
|
const explicitImageInput: ImageInput = ImageInput.url("https://example.com/image.png")
|
||||||
|
void explicitImageInput
|
||||||
|
|
||||||
|
// @ts-expect-error Raw strings are ambiguous and are not image inputs.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", images: ["AQID"] })
|
||||||
|
// @ts-expect-error Byte image inputs require an explicit MIME type.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", images: [{ type: "bytes", data: new Uint8Array() }] })
|
||||||
|
// @ts-expect-error File URIs require an explicit MIME type for Gemini fileData.
|
||||||
|
Image.generate({ model: google, prompt: "A lighthouse", images: [{ type: "file-uri", uri: "files/123" }] })
|
||||||
|
|
||||||
const request = Image.request({
|
const request = Image.request({
|
||||||
model: google,
|
model: google,
|
||||||
|
|
@ -93,3 +157,5 @@ Image.generate({ model: openai, prompt: "A lighthouse", aspectRatio: "16:9" })
|
||||||
Image.generate({ model: openai, prompt: "A lighthouse", seed: 1 })
|
Image.generate({ model: openai, prompt: "A lighthouse", seed: 1 })
|
||||||
// @ts-expect-error Image requests do not expose metadata.
|
// @ts-expect-error Image requests do not expose metadata.
|
||||||
Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } })
|
Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } })
|
||||||
|
// @ts-expect-error Masks are provider options, not a common image request field.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", mask: ImageInput.url("https://example.com/mask.png") })
|
||||||
|
|
|
||||||
29
packages/ai/test/lib/image.ts
Normal file
29
packages/ai/test/lib/image.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
export const dimensions = (data: Uint8Array) => {
|
||||||
|
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
||||||
|
return {
|
||||||
|
width: readUint32(data, 16),
|
||||||
|
height: readUint32(data, 20),
|
||||||
|
}
|
||||||
|
if (data[0] === 0xff && data[1] === 0xd8) {
|
||||||
|
for (let offset = 2; offset + 8 < data.length; ) {
|
||||||
|
if (data[offset] !== 0xff) {
|
||||||
|
offset++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const marker = data[offset + 1]
|
||||||
|
if (
|
||||||
|
marker !== undefined &&
|
||||||
|
[0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
width: (data[offset + 7] << 8) | data[offset + 8],
|
||||||
|
height: (data[offset + 5] << 8) | data[offset + 6],
|
||||||
|
}
|
||||||
|
offset += 2 + ((data[offset + 2] << 8) | data[offset + 3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported image fixture format")
|
||||||
|
}
|
||||||
|
|
||||||
|
const readUint32 = (data: Uint8Array, offset: number) =>
|
||||||
|
((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]) >>> 0
|
||||||
56
packages/ai/test/provider/google-images.recorded.test.ts
Normal file
56
packages/ai/test/provider/google-images.recorded.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Image, ImageInput } from "../../src"
|
||||||
|
import { Google } from "../../src/providers"
|
||||||
|
import { dimensions } from "../lib/image"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = Google.configure({
|
||||||
|
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
|
||||||
|
}).image("gemini-3.1-flash-image")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "google-images",
|
||||||
|
provider: "google",
|
||||||
|
protocol: "google-images",
|
||||||
|
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Google Images recorded", () => {
|
||||||
|
recorded.effect("generates an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "A simple flat blue circle centered on a plain white background.",
|
||||||
|
options: { aspectRatio: "1:1" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toMatch(/^image\//)
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect("edits an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt:
|
||||||
|
"Transform this minimal source into a bright orange sun icon with eight rounded rays on a pale blue background.",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(
|
||||||
|
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||||
|
"image/jpeg",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options: { aspectRatio: "1:1" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned Google image bytes")
|
||||||
|
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Image } from "../../src"
|
import { Image, ImageInput } from "../../src"
|
||||||
import { OpenAI } from "../../src/providers"
|
import { OpenAI } from "../../src/providers"
|
||||||
|
import { dimensions } from "../lib/image"
|
||||||
import { recordedTests } from "../recorded-test"
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
const model = OpenAI.configure({
|
const model = OpenAI.configure({
|
||||||
|
|
@ -30,4 +31,32 @@ describe("OpenAI Images recorded", () => {
|
||||||
expect(response.image?.data.length).toBeGreaterThan(0)
|
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recorded.effect.with(
|
||||||
|
"edits an image",
|
||||||
|
{
|
||||||
|
options: {
|
||||||
|
match: (incoming, recorded) => incoming.method === recorded.method && incoming.url === recorded.url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "Keep the simple shape and change it from black to bright green.",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(
|
||||||
|
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||||
|
"image/jpeg",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned OpenAI image bytes")
|
||||||
|
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Image } from "../../src"
|
import { Image, ImageInput } from "../../src"
|
||||||
import { XAI } from "../../src/providers"
|
import { XAI } from "../../src/providers"
|
||||||
|
import { dimensions } from "../lib/image"
|
||||||
import { recordedTests } from "../recorded-test"
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
const model = XAI.configure({
|
const model = XAI.configure({
|
||||||
|
|
@ -30,4 +31,25 @@ describe("xAI Images recorded", () => {
|
||||||
expect(response.image?.data.length).toBeGreaterThan(0)
|
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recorded.effect("edits an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "Keep the simple shape and change it from black to bright purple.",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(
|
||||||
|
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||||
|
"image/jpeg",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.image?.mediaType).toMatch(/^image\/(jpeg|png)$/)
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned xAI image bytes")
|
||||||
|
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
32
packages/ai/test/provider/zai-images.recorded.test.ts
Normal file
32
packages/ai/test/provider/zai-images.recorded.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Image } from "../../src"
|
||||||
|
import { ZAI } from "../../src/providers"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = ZAI.configure({ apiKey: process.env.ZAI_API_KEY ?? "fixture" }).image("cogview-4-250304")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "zai-images",
|
||||||
|
provider: "zai",
|
||||||
|
protocol: "zai-images",
|
||||||
|
requires: ["ZAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Z.ai Images recorded", () => {
|
||||||
|
recorded.effect("generates an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "A simple flat red circle centered on a plain white background.",
|
||||||
|
options: { size: "1024x1024", quality: "standard", userID: "opencode-image-test" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toBe("application/octet-stream")
|
||||||
|
expect(response.image?.data).toBeString()
|
||||||
|
expect(response.image?.data).toStartWith("https://")
|
||||||
|
expect(response.providerMetadata?.zai).toBeDefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
130
packages/ai/test/provider/zai-images.test.ts
Normal file
130
packages/ai/test/provider/zai-images.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { Image, ImageClient } from "../../src"
|
||||||
|
import { ZAI } from "../../src/providers"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||||
|
|
||||||
|
describe("Z.ai Images", () => {
|
||||||
|
it.effect("generates through the Z.ai Images API", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: ZAI.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://api.z.ai.test/api/paas/v4",
|
||||||
|
headers: { "x-default": "yes" },
|
||||||
|
http: { body: { configured: true, quality: "configured" }, query: { trace: "default" } },
|
||||||
|
}).image("glm-image"),
|
||||||
|
prompt: "A red circle on a white background",
|
||||||
|
options: {
|
||||||
|
quality: "hd",
|
||||||
|
userID: "alias-user",
|
||||||
|
user_id: "raw-user",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
headers: { "x-request": "yes" },
|
||||||
|
query: { trace: "request" },
|
||||||
|
body: { quality: "final", user_id: "final-user" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toBe("application/octet-stream")
|
||||||
|
expect(response.image?.data).toBe("https://cdn.z.ai/generated.png")
|
||||||
|
expect(response.providerMetadata).toEqual({
|
||||||
|
zai: {
|
||||||
|
created: 1_760_335_349,
|
||||||
|
id: "generation-1",
|
||||||
|
requestID: "request-1",
|
||||||
|
contentFilter: [{ role: "future-role", level: 4.5 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe("https://api.z.ai.test/api/paas/v4/images/generations?trace=request")
|
||||||
|
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||||
|
expect(request.headers.get("x-default")).toBe("yes")
|
||||||
|
expect(request.headers.get("x-request")).toBe("yes")
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "glm-image",
|
||||||
|
prompt: "A red circle on a white background",
|
||||||
|
quality: "final",
|
||||||
|
user_id: "final-user",
|
||||||
|
future_option: true,
|
||||||
|
configured: true,
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
created: 1_760_335_349,
|
||||||
|
id: "generation-1",
|
||||||
|
request_id: "request-1",
|
||||||
|
data: [{ url: "https://cdn.z.ai/generated.png" }],
|
||||||
|
content_filter: [{ role: "future-role", level: 4.5 }],
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lets raw native options override aliases", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
||||||
|
prompt: "test",
|
||||||
|
options: { quality: "future-quality", userID: "x", user_id: "raw-user" },
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" })
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects invalid response structures", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = ZAI.configure({ apiKey: "test" }).image("model")
|
||||||
|
const payloads = [
|
||||||
|
{},
|
||||||
|
{ data: [] },
|
||||||
|
{ data: [{ b64_json: "image" }] },
|
||||||
|
{ data: [{ url: 1 }] },
|
||||||
|
{ data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: 1, level: "high" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
yield* Effect.forEach(payloads, (payload) =>
|
||||||
|
Image.generate({ model, prompt: "test" }).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
fixedResponse(JSON.stringify(payload), { headers: { "content-type": "application/json" } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -38,17 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||||
instance.scrollOffset = offset
|
instance.scrollOffset = offset
|
||||||
})
|
})
|
||||||
|
|
||||||
document.body.append(unrelated)
|
|
||||||
unrelated.remove()
|
|
||||||
await frames(2)
|
|
||||||
expect(calls).toEqual([])
|
|
||||||
|
|
||||||
route.remove()
|
route.remove()
|
||||||
document.body.append(route)
|
document.body.append(route)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
await frames(3)
|
await frames(3)
|
||||||
expect(calls).toEqual([[0, false]])
|
expect(calls).toEqual([[0, false]])
|
||||||
|
|
||||||
|
document.body.append(unrelated)
|
||||||
|
unrelated.remove()
|
||||||
|
await frames(2)
|
||||||
|
expect(calls).toEqual([[0, false]])
|
||||||
|
|
||||||
route.remove()
|
route.remove()
|
||||||
document.body.append(route)
|
document.body.append(route)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,18 @@
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "0.21.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
"@opencode-ai/tui": "workspace:*",
|
"@opencode-ai/tui": "workspace:*",
|
||||||
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||||
import { createRequire } from "node:module"
|
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
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: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
|
||||||
{
|
{
|
||||||
key: photonWasmAsset,
|
key: photonWasmAsset,
|
||||||
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
|
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||||
},
|
},
|
||||||
...attentionSoundAssets.map((key) => ({
|
...attentionSoundAssets.map((key) => ({
|
||||||
key,
|
key,
|
||||||
|
|
|
||||||
65
packages/cli/src/acp/agent.ts
Normal file
65
packages/cli/src/acp/agent.ts
Normal 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
packages/cli/src/acp/config-option.ts
Normal file
133
packages/cli/src/acp/config-option.ts
Normal 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
packages/cli/src/acp/content.ts
Normal file
183
packages/cli/src/acp/content.ts
Normal 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"
|
||||||
|
|
@ -7,9 +7,7 @@ export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoun
|
||||||
|
|
||||||
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
|
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
|
||||||
"ACPInvalidConfigOptionError",
|
"ACPInvalidConfigOptionError",
|
||||||
{
|
{ configId: Schema.String },
|
||||||
configId: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
|
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
|
||||||
|
|
@ -25,22 +23,11 @@ export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>(
|
||||||
mode: Schema.String,
|
mode: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {
|
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {}) {}
|
||||||
providerId: Schema.optional(Schema.String),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
|
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
|
||||||
"ACPUnknownAuthMethodError",
|
"ACPUnknownAuthMethodError",
|
||||||
{
|
{ methodId: Schema.String },
|
||||||
methodId: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
|
|
||||||
"ACPUnsupportedOperationError",
|
|
||||||
{
|
|
||||||
method: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
||||||
|
|
@ -57,10 +44,9 @@ export type Error =
|
||||||
| InvalidModeError
|
| InvalidModeError
|
||||||
| AuthRequiredError
|
| AuthRequiredError
|
||||||
| UnknownAuthMethodError
|
| UnknownAuthMethodError
|
||||||
| UnsupportedOperationError
|
|
||||||
| ServiceFailureError
|
| ServiceFailureError
|
||||||
|
|
||||||
export function toRequestError(error: Error) {
|
export function toRequestError(error: Error): RequestError {
|
||||||
switch (error._tag) {
|
switch (error._tag) {
|
||||||
case "ACPSessionNotFoundError":
|
case "ACPSessionNotFoundError":
|
||||||
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
|
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
|
||||||
|
|
@ -76,11 +62,9 @@ export function toRequestError(error: Error) {
|
||||||
case "ACPInvalidModeError":
|
case "ACPInvalidModeError":
|
||||||
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
|
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
|
||||||
case "ACPAuthRequiredError":
|
case "ACPAuthRequiredError":
|
||||||
return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
|
return RequestError.authRequired({}, "provider authentication required")
|
||||||
case "ACPUnknownAuthMethodError":
|
case "ACPUnknownAuthMethodError":
|
||||||
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
|
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
|
||||||
case "ACPUnsupportedOperationError":
|
|
||||||
return RequestError.methodNotFound(error.method)
|
|
||||||
case "ACPServiceFailureError":
|
case "ACPServiceFailureError":
|
||||||
return RequestError.internalError(
|
return RequestError.internalError(
|
||||||
{
|
{
|
||||||
|
|
@ -90,8 +74,13 @@ export function toRequestError(error: Error) {
|
||||||
error.safeMessage,
|
error.safeMessage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const exhaustive: never = error
|
||||||
|
return exhaustive
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromUnknownDefect(_defect: unknown, safeMessage = "Internal service failure") {
|
export function fromUnknown(error: unknown, service?: string) {
|
||||||
return new ServiceFailureError({ safeMessage })
|
const errorName = error instanceof Error ? error.name : undefined
|
||||||
|
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * as ACPError from "./error"
|
||||||
441
packages/cli/src/acp/event.ts
Normal file
441
packages/cli/src/acp/event.ts
Normal 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
packages/cli/src/acp/permission.ts
Normal file
179
packages/cli/src/acp/permission.ts
Normal 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
packages/cli/src/acp/service.ts
Normal file
531
packages/cli/src/acp/service.ts
Normal 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
packages/cli/src/acp/tool.ts
Normal file
222
packages/cli/src/acp/tool.ts
Normal 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"
|
||||||
|
|
@ -34,6 +34,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
commands: [
|
commands: [
|
||||||
|
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
||||||
Spec.make("api", {
|
Spec.make("api", {
|
||||||
description: "Make a request to the running server",
|
description: "Make a request to the running server",
|
||||||
params: {
|
params: {
|
||||||
|
|
|
||||||
36
packages/cli/src/commands/handlers/acp.ts
Normal file
36
packages/cli/src/commands/handlers/acp.ts
Normal 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 { Cause, Effect, Exit, Option } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
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 { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { run } from "@opencode-ai/tui"
|
import { run } from "@opencode-ai/tui"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
|
|
@ -8,7 +8,7 @@ import { Context, Effect, FileSystem, Option } from "effect"
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
import { UpdatePreflight } from "../../services/update-preflight"
|
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) =>
|
export default Runtime.handler(Commands, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import path from "node:path"
|
||||||
import { readFile, stat, writeFile } from "node:fs/promises"
|
import { readFile, stat, writeFile } from "node:fs/promises"
|
||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { applyEdits, modify } from "jsonc-parser"
|
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 { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
|
|
||||||
|
|
@ -30,7 +30,8 @@ export default Runtime.handler(
|
||||||
? { type: "remote" as const, url, ...(headers ? { headers } : {}) }
|
? { type: "remote" as const, url, ...(headers ? { headers } : {}) }
|
||||||
: { type: "local" as const, command, ...(environment ? { environment } : {}) }
|
: { type: "local" as const, command, ...(environment ? { environment } : {}) }
|
||||||
|
|
||||||
const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd()))
|
const global = yield* Global.Service
|
||||||
|
const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? global.config : process.cwd()))
|
||||||
yield* Effect.promise(() => write(configPath, input.name, server))
|
yield* Effect.promise(() => write(configPath, input.name, server))
|
||||||
process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL)
|
process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL)
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||||
replayLimit: Option.getOrUndefined(input.replayLimit),
|
replayLimit: Option.getOrUndefined(input.replayLimit),
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
tuiConfig: resolved,
|
tuiConfig: resolved,
|
||||||
|
config: {
|
||||||
|
update: (update) => runServicePromise(config.update(update)),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * as Config from "./config"
|
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 { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||||
import { produce, type Draft } from "immer"
|
import { produce, type Draft } from "immer"
|
||||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Effect, FileSystem, Scope } from "effect"
|
import { Effect, FileSystem, Scope } from "effect"
|
||||||
import { Command } from "effect/unstable/cli"
|
import { Command } from "effect/unstable/cli"
|
||||||
import { Spec } from "./spec"
|
import { Spec } from "./spec"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Updater } from "../services/updater"
|
import { Updater } from "../services/updater"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
|
|
||||||
export type Input<Value> =
|
export type Input<Value> =
|
||||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Commands } from "./commands/commands"
|
import { Commands } from "./commands/commands"
|
||||||
import { Runtime } from "./framework/runtime"
|
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 { Updater } from "./services/updater"
|
||||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
|
|
||||||
const Handlers = Runtime.handlers(Commands, {
|
const Handlers = Runtime.handlers(Commands, {
|
||||||
$: () => import("./commands/handlers/default"),
|
$: () => import("./commands/handlers/default"),
|
||||||
|
acp: () => import("./commands/handlers/acp"),
|
||||||
api: () => import("./commands/handlers/api"),
|
api: () => import("./commands/handlers/api"),
|
||||||
auth: {
|
auth: {
|
||||||
connect: () => import("./commands/handlers/auth/connect"),
|
connect: () => import("./commands/handlers/auth/connect"),
|
||||||
|
|
@ -60,8 +62,20 @@ Effect.logInfo("cli starting", {
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
Effect.provide(Config.layer),
|
Effect.provide(Config.layer),
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
Effect.provide(
|
||||||
Effect.provide(Observability.layer),
|
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [
|
||||||
|
[
|
||||||
|
Global.node,
|
||||||
|
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
Effect.provide(
|
||||||
|
Observability.layer({
|
||||||
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
||||||
|
}).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))),
|
||||||
|
),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||||
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
|
@ -165,7 +164,7 @@ export function createMiniHost(input: {
|
||||||
sigusr2: signal("SIGUSR2"),
|
sigusr2: signal("SIGUSR2"),
|
||||||
},
|
},
|
||||||
startup: {
|
startup: {
|
||||||
showTiming: Flag.OPENCODE_SHOW_TTFD,
|
showTiming: ["1", "true"].includes(process.env.OPENCODE_SHOW_TTFD?.toLowerCase() ?? ""),
|
||||||
now: () => performance.now(),
|
now: () => performance.now(),
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export type MiniCommandInput = {
|
||||||
replayLimit?: number
|
replayLimit?: number
|
||||||
demo?: boolean
|
demo?: boolean
|
||||||
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||||
|
config?: MiniFrontendInput["config"]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model = MiniFrontendInput["model"]
|
type Model = MiniFrontendInput["model"]
|
||||||
|
|
@ -119,6 +120,7 @@ export async function runMini(input: MiniCommandInput) {
|
||||||
replayLimit: input.replayLimit,
|
replayLimit: input.replayLimit,
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
tuiConfig: input.tuiConfig,
|
tuiConfig: input.tuiConfig,
|
||||||
|
config: input.config,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (result.exitCode !== 0) process.exit(result.exitCode)
|
if (result.exitCode !== 0) process.exit(result.exitCode)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
LocationRef,
|
LocationRef,
|
||||||
OpenCodeClient,
|
OpenCodeClient,
|
||||||
SessionMessageAssistantTool,
|
SessionMessageAssistantTool,
|
||||||
|
SessionMessageInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
|
|
@ -75,6 +76,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
const messageID = SessionMessage.ID.create()
|
const messageID = SessionMessage.ID.create()
|
||||||
const starts = new Map<string, StartedPart>()
|
const starts = new Map<string, StartedPart>()
|
||||||
const tools = new Map<string, ToolState>()
|
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 submitted = false
|
||||||
let promoted = false
|
let promoted = false
|
||||||
let emittedError = false
|
let emittedError = false
|
||||||
|
|
@ -82,6 +86,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
let formCancelled = false
|
let formCancelled = false
|
||||||
let interrupted = false
|
let interrupted = false
|
||||||
let v1InvalidOutput = false
|
let v1InvalidOutput = false
|
||||||
|
let prePromotionError: { message: string; [key: string]: unknown } | undefined
|
||||||
|
let finalizing = false
|
||||||
let admission: AbortController | undefined
|
let admission: AbortController | undefined
|
||||||
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
|
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
|
||||||
|
|
||||||
|
|
@ -104,6 +110,17 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
UI.empty()
|
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 = () => {
|
const flushStep = () => {
|
||||||
if (!pendingStep) return
|
if (!pendingStep) return
|
||||||
const value = pendingStep
|
const value = pendingStep
|
||||||
|
|
@ -181,6 +198,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
if (event.type === "session.input.promoted") {
|
if (event.type === "session.input.promoted") {
|
||||||
if (event.data.inputID === messageID) {
|
if (event.data.inputID === messageID) {
|
||||||
promoted = true
|
promoted = true
|
||||||
|
prePromotionError = undefined
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +209,12 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!promoted && event.type === "session.execution.failed") {
|
||||||
|
prePromotionError = event.data.error
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (!promoted) continue
|
if (!promoted) continue
|
||||||
|
if (finalizing) continue
|
||||||
|
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
const part = {
|
const part = {
|
||||||
|
|
@ -219,12 +242,16 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
|
|
||||||
if (event.type === "session.text.started") {
|
if (event.type === "session.text.started") {
|
||||||
flushStep()
|
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
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.ended") {
|
if (event.type === "session.text.ended") {
|
||||||
const started = starts.get("text")
|
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
|
||||||
starts.delete("text")
|
const started = starts.get(`text\u0000${key}`)
|
||||||
|
starts.delete(`text\u0000${key}`)
|
||||||
const part = {
|
const part = {
|
||||||
id: started?.id ?? partID(event.id),
|
id: started?.id ?? partID(event.id),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -233,18 +260,23 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
time: { start: started?.timestamp ?? time, end: time },
|
time: { start: started?.timestamp ?? time, end: time },
|
||||||
}
|
}
|
||||||
|
renderedText.set(key, event.data.text)
|
||||||
writeText(part, time)
|
writeText(part, time)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.reasoning.started") {
|
if (event.type === "session.reasoning.started") {
|
||||||
flushStep()
|
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
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.ended" && input.thinking) {
|
if (event.type === "session.reasoning.ended" && input.thinking) {
|
||||||
const started = starts.get("reasoning")
|
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
|
||||||
starts.delete("reasoning")
|
const started = starts.get(`reasoning\u0000${key}`)
|
||||||
|
starts.delete(`reasoning\u0000${key}`)
|
||||||
const part = {
|
const part = {
|
||||||
id: started?.id ?? partID(event.id),
|
id: started?.id ?? partID(event.id),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -254,17 +286,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
metadata: event.data.state,
|
metadata: event.data.state,
|
||||||
time: { start: started?.timestamp ?? time, end: time },
|
time: { start: started?.timestamp ?? time, end: time },
|
||||||
}
|
}
|
||||||
if (emit("reasoning", time, { part })) continue
|
renderedReasoning.set(key, event.data.text)
|
||||||
const text = part.text.trim()
|
writeReasoning(part, time)
|
||||||
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()
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,6 +383,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
tools.delete(key)
|
tools.delete(key)
|
||||||
|
renderedTools.add(key)
|
||||||
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
|
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -405,6 +429,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
tools.delete(key)
|
tools.delete(key)
|
||||||
|
renderedTools.add(key)
|
||||||
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
||||||
if (!emit("tool_use", time, { part })) {
|
if (!emit("tool_use", time, { part })) {
|
||||||
if (toolOutputText(current.tool, current.content).trim())
|
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 = () => {
|
const interrupt = () => {
|
||||||
if (interrupted) process.exit(130)
|
if (interrupted) process.exit(130)
|
||||||
interrupted = true
|
interrupted = true
|
||||||
|
|
@ -559,11 +700,27 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
|
? 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 {
|
} finally {
|
||||||
process.off("SIGINT", interrupt)
|
process.off("SIGINT", interrupt)
|
||||||
controller.abort()
|
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}`
|
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: {
|
function fallbackTool(event: {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
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 { open } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { readStdin } from "../util/io"
|
import { readStdin } from "../util/io"
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ export * as ServerProcess from "./server-process"
|
||||||
|
|
||||||
import { NodeServices } from "@effect/platform-node"
|
import { NodeServices } from "@effect/platform-node"
|
||||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { randomBytes, randomUUID } from "node:crypto"
|
import { randomBytes, randomUUID } from "node:crypto"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||||
|
|
@ -26,7 +26,14 @@ export type Options = {
|
||||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
export const run = Effect.fnUntraced(function* (options: Options) {
|
||||||
return yield* processEffect(options).pipe(
|
return yield* processEffect(options).pipe(
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))),
|
Effect.provide(
|
||||||
|
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
|
||||||
|
[
|
||||||
|
Global.node,
|
||||||
|
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -60,30 +67,63 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||||
: randomBytes(32).toString("base64url")
|
: randomBytes(32).toString("base64url")
|
||||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||||
const instanceID = randomUUID()
|
const instanceID = randomUUID()
|
||||||
const server = yield* start({
|
const server = yield* start(
|
||||||
hostname,
|
{
|
||||||
port: Option.fromNullishOr(port),
|
client: process.env.OPENCODE_CLIENT ?? "cli",
|
||||||
password,
|
hostname,
|
||||||
instanceID,
|
port,
|
||||||
database: {
|
password,
|
||||||
path: process.env.OPENCODE_DB,
|
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||||
|
database: {
|
||||||
|
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,
|
||||||
|
file: process.env.OPENCODE_MODELS_PATH,
|
||||||
|
fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH),
|
||||||
|
},
|
||||||
|
observability: {
|
||||||
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
directory: process.env.OPENCODE_CONFIG_DIR,
|
||||||
|
project: !truthy(
|
||||||
|
process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
|
||||||
|
),
|
||||||
|
file: process.env.OPENCODE_CONFIG,
|
||||||
|
content: process.env.OPENCODE_CONFIG_CONTENT,
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
filewatcher: !truthy(
|
||||||
|
process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER,
|
||||||
|
),
|
||||||
|
fff:
|
||||||
|
process.env.OPENCODE_DISABLE_FFF === undefined
|
||||||
|
? process.platform !== "win32"
|
||||||
|
: !truthy(process.env.OPENCODE_DISABLE_FFF),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
models: {
|
serviceOptions === undefined
|
||||||
url: process.env.OPENCODE_MODELS_URL,
|
? undefined
|
||||||
file: process.env.OPENCODE_MODELS_PATH,
|
: {
|
||||||
fetch: !["1", "true"].includes(process.env.OPENCODE_DISABLE_MODELS_FETCH?.toLowerCase() ?? ""),
|
instanceID,
|
||||||
},
|
onListen: (address, shutdown) =>
|
||||||
service:
|
Effect.gen(function* () {
|
||||||
serviceOptions === undefined
|
if (!config.password) yield* ServiceConfig.password(password)
|
||||||
? undefined
|
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||||
: {
|
}),
|
||||||
onListen: (address, shutdown) =>
|
},
|
||||||
Effect.gen(function* () {
|
).pipe(
|
||||||
if (!config.password) yield* ServiceConfig.password(password)
|
|
||||||
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
|
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
|
||||||
Effect.catch((error) => {
|
Effect.catch((error) => {
|
||||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||||
|
|
@ -176,6 +216,10 @@ function serviceURL(hostname: string, port: number) {
|
||||||
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truthy(value?: string) {
|
||||||
|
return value === "1" || value?.toLowerCase() === "true"
|
||||||
|
}
|
||||||
|
|
||||||
function addressInUse(error: unknown): boolean {
|
function addressInUse(error: unknown): boolean {
|
||||||
if (typeof error !== "object" || error === null) return false
|
if (typeof error !== "object" || error === null) return false
|
||||||
if ("code" in error && error.code === "EADDRINUSE") return true
|
if ("code" in error && error.code === "EADDRINUSE") return true
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
|
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
|
||||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
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 { Effect, Redacted } from "effect"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import { ServiceConfig } from "./service-config"
|
import { ServiceConfig } from "./service-config"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/util/hash"
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
|
|
@ -25,17 +25,21 @@ const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
|
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
|
||||||
|
|
||||||
export function filename(channel = InstallationChannel) {
|
export function filename(channel = InstallationChannel) {
|
||||||
if (channel === "latest") return "service.json"
|
if (channel === "latest" || channel === "next") return "service.json"
|
||||||
if (channel === "local") return "service-local.json"
|
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||||
return `service-${Hash.fast(channel)}.json`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultPort(channel = InstallationChannel) {
|
export function defaultPort(channel = InstallationChannel) {
|
||||||
if (channel === "latest") return 0xc0de
|
if (channel === "latest" || channel === "next") return 0xc0de
|
||||||
if (channel === "local") return 0xc0df
|
if (channel === "local") return 0xc0df
|
||||||
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
|
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(
|
export function versionBelongsToChannel(
|
||||||
version: string | undefined,
|
version: string | undefined,
|
||||||
channel = InstallationChannel,
|
channel = InstallationChannel,
|
||||||
|
|
@ -54,7 +58,6 @@ export const migrateRegistration = Effect.fnUntraced(function* (
|
||||||
channel = InstallationChannel,
|
channel = InstallationChannel,
|
||||||
installedVersion = InstallationVersion,
|
installedVersion = InstallationVersion,
|
||||||
) {
|
) {
|
||||||
if (channel === "latest" || channel === "local") return
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
|
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
|
||||||
if (Option.isNone(text)) return
|
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)
|
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 {
|
function configKey(key: string): Key {
|
||||||
if (key === "hostname" || key === "port" || key === "password") return key
|
if (key === "hostname" || key === "port" || key === "password") return key
|
||||||
throw new Error(`Unknown service config key: ${key}`)
|
throw new Error(`Unknown service config key: ${key}`)
|
||||||
|
|
@ -73,18 +84,23 @@ const paths = Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const name = filename()
|
const name = filename()
|
||||||
|
const legacy = legacyFilename()
|
||||||
const file = path.join(global.state, name)
|
const file = path.join(global.state, name)
|
||||||
return {
|
return {
|
||||||
fs,
|
fs,
|
||||||
file,
|
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),
|
configFile: path.join(global.config, name),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const options = Effect.fnUntraced(function* () {
|
export const options = Effect.fnUntraced(function* () {
|
||||||
const { file, legacyFile } = yield* paths
|
const { file, legacyRegistrationFiles } = yield* paths
|
||||||
yield* migrateRegistration(legacyFile, file)
|
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
version: InstallationVersion,
|
version: InstallationVersion,
|
||||||
|
|
@ -93,7 +109,8 @@ export const options = Effect.fnUntraced(function* () {
|
||||||
})
|
})
|
||||||
|
|
||||||
export const read = Effect.fn("cli.service-config.read")(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(
|
return yield* fs.readFileString(configFile).pipe(
|
||||||
Effect.flatMap(decodeInfo),
|
Effect.flatMap(decodeInfo),
|
||||||
Effect.catch(() => Effect.succeed({} as Info)),
|
Effect.catch(() => Effect.succeed({} as Info)),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Deferred, Effect, Schema, Stream } from "effect"
|
import { Deferred, Effect, Schema, Stream } from "effect"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import { randomBytes } from "node:crypto"
|
import { randomBytes } from "node:crypto"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// version-mismatched background service before the TUI attaches.
|
// version-mismatched background service before the TUI attaches.
|
||||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
||||||
import { render, useTerminalDimensions } from "@opentui/solid"
|
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 { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||||
import { go } from "@opencode-ai/tui/logo"
|
import { go } from "@opencode-ai/tui/logo"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
|
||||||
import {
|
import {
|
||||||
InstallationChannel,
|
InstallationChannel,
|
||||||
InstallationLocal,
|
InstallationLocal,
|
||||||
InstallationVersion,
|
InstallationVersion,
|
||||||
} from "@opencode-ai/core/installation/version"
|
} from "@opencode-ai/util/installation/version"
|
||||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { parse, type ParseError } from "jsonc-parser"
|
import { parse, type ParseError } from "jsonc-parser"
|
||||||
|
|
@ -138,7 +137,10 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const check = Effect.fn("cli.updater.check")(function* () {
|
const check = Effect.fn("cli.updater.check")(function* () {
|
||||||
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
|
if (
|
||||||
|
InstallationLocal ||
|
||||||
|
["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
|
||||||
|
)
|
||||||
return yield* Effect.logInfo("update check skipped", {
|
return yield* Effect.logInfo("update check skipped", {
|
||||||
reason: InstallationLocal ? "local-install" : "disabled",
|
reason: InstallationLocal ? "local-install" : "disabled",
|
||||||
version: InstallationVersion,
|
version: InstallationVersion,
|
||||||
|
|
|
||||||
107
packages/cli/test/acp/command.test.ts
Normal file
107
packages/cli/test/acp/command.test.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -4,46 +4,21 @@ import {
|
||||||
buildEffortSelectOption,
|
buildEffortSelectOption,
|
||||||
buildModeSelectOption,
|
buildModeSelectOption,
|
||||||
buildModelSelectOption,
|
buildModelSelectOption,
|
||||||
formatCurrentModelId,
|
|
||||||
formatVariantName,
|
formatVariantName,
|
||||||
parseModelSelection,
|
parseModelSelection,
|
||||||
type ConfigOptionProvider,
|
type ConfigOptionProvider,
|
||||||
} from "@/acp/config-option"
|
} from "../../src/acp/config-option"
|
||||||
|
|
||||||
const providers: ConfigOptionProvider[] = [
|
const providers: ConfigOptionProvider[] = [
|
||||||
{
|
{
|
||||||
id: "anthropic",
|
id: "anthropic",
|
||||||
name: "Anthropic",
|
name: "Anthropic",
|
||||||
models: {
|
models: [
|
||||||
"claude/sonnet-4": {
|
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
|
||||||
id: "claude/sonnet-4",
|
{ id: "claude-haiku", name: "Claude Haiku" },
|
||||||
name: "Claude Sonnet 4",
|
],
|
||||||
variants: {
|
|
||||||
default: {},
|
|
||||||
high: {},
|
|
||||||
"very-high": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"claude-haiku": {
|
|
||||||
id: "claude-haiku",
|
|
||||||
name: "Claude Haiku",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "openai",
|
|
||||||
name: "OpenAI",
|
|
||||||
models: {
|
|
||||||
"gpt-5": {
|
|
||||||
id: "gpt-5",
|
|
||||||
name: "GPT-5",
|
|
||||||
variants: {
|
|
||||||
minimal: {},
|
|
||||||
low: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
|
||||||
]
|
]
|
||||||
|
|
||||||
describe("acp config options", () => {
|
describe("acp config options", () => {
|
||||||
|
|
@ -52,7 +27,6 @@ describe("acp config options", () => {
|
||||||
buildModelSelectOption({
|
buildModelSelectOption({
|
||||||
providers,
|
providers,
|
||||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||||
currentVariant: "high",
|
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
id: "model",
|
id: "model",
|
||||||
|
|
@ -68,26 +42,6 @@ describe("acp config options", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("includes variant ids in the model option only when requested", () => {
|
|
||||||
const option = buildModelSelectOption({
|
|
||||||
providers,
|
|
||||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
|
||||||
currentVariant: "high",
|
|
||||||
includeVariants: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(option.currentValue).toBe("anthropic/claude/sonnet-4/high")
|
|
||||||
if (option.type !== "select") throw new Error("expected select option")
|
|
||||||
expect(option.options).toContainEqual({
|
|
||||||
value: "anthropic/claude/sonnet-4/high",
|
|
||||||
name: "Anthropic/Claude Sonnet 4 (High)",
|
|
||||||
})
|
|
||||||
expect(option.options).not.toContainEqual({
|
|
||||||
value: "anthropic/claude/sonnet-4/default",
|
|
||||||
name: "Anthropic/Claude Sonnet 4 (Default)",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
|
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({
|
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
|
||||||
id: "effort",
|
id: "effort",
|
||||||
|
|
@ -105,15 +59,11 @@ describe("acp config options", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("effort fallback uses the first variant when default is absent", () => {
|
test("effort fallback uses the first variant when default is absent", () => {
|
||||||
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" })?.currentValue).toBe(
|
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
|
||||||
"minimal",
|
"minimal",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("omits effort option when there are no variants", () => {
|
|
||||||
expect(buildEffortSelectOption({ variants: [] })).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("builds the mode select option with descriptions when present", () => {
|
test("builds the mode select option with descriptions when present", () => {
|
||||||
expect(
|
expect(
|
||||||
buildModeSelectOption({
|
buildModeSelectOption({
|
||||||
|
|
@ -150,6 +100,7 @@ describe("acp config options", () => {
|
||||||
|
|
||||||
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||||
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "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")
|
expect(options[1]?.currentValue).toBe("very-high")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -194,35 +145,6 @@ describe("acp config options", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats current model ids with and without selected variants", () => {
|
|
||||||
expect(
|
|
||||||
formatCurrentModelId({
|
|
||||||
model: { providerID: "openai", modelID: "gpt-5" },
|
|
||||||
variant: "low",
|
|
||||||
variants: ["minimal", "low"],
|
|
||||||
}),
|
|
||||||
).toBe("openai/gpt-5")
|
|
||||||
expect(
|
|
||||||
formatCurrentModelId({
|
|
||||||
model: { providerID: "openai", modelID: "gpt-5" },
|
|
||||||
variant: "low",
|
|
||||||
variants: ["minimal", "low"],
|
|
||||||
includeVariant: true,
|
|
||||||
}),
|
|
||||||
).toBe("openai/gpt-5/low")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats current model ids with variant fallback", () => {
|
|
||||||
expect(
|
|
||||||
formatCurrentModelId({
|
|
||||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
|
||||||
variant: "missing",
|
|
||||||
variants: ["default", "high"],
|
|
||||||
includeVariant: true,
|
|
||||||
}),
|
|
||||||
).toBe("anthropic/claude/sonnet-4/default")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats variant names for display", () => {
|
test("formats variant names for display", () => {
|
||||||
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
|
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
|
||||||
})
|
})
|
||||||
74
packages/cli/test/acp/config-options.subprocess.test.ts
Normal file
74
packages/cli/test/acp/config-options.subprocess.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { ContentBlock } from "@agentclientprotocol/sdk"
|
|
||||||
import { pathToFileURL } from "node:url"
|
import { pathToFileURL } from "node:url"
|
||||||
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
|
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
|
||||||
|
|
||||||
|
|
@ -188,7 +187,12 @@ describe("acp content conversion", () => {
|
||||||
|
|
||||||
test("unsupported blocks are ignored", () => {
|
test("unsupported blocks are ignored", () => {
|
||||||
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
|
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
|
||||||
expect(promptContentToParts([{ type: "unknown", text: "skip" } as unknown as ContentBlock])).toEqual([])
|
expect(
|
||||||
|
promptContentToParts([
|
||||||
|
// @ts-expect-error Exercise forward compatibility with an unknown ACP content block.
|
||||||
|
{ type: "unknown", text: "skip" },
|
||||||
|
]),
|
||||||
|
).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { RequestError } from "@agentclientprotocol/sdk"
|
import { RequestError } from "@agentclientprotocol/sdk"
|
||||||
import * as ACPError from "../../src/acp/error"
|
import { ACPError } from "../../src/acp/error"
|
||||||
|
|
||||||
describe("acp.error", () => {
|
describe("acp errors", () => {
|
||||||
test("maps validation failures to invalid params", () => {
|
test("maps validation failures to invalid params", () => {
|
||||||
const cases: ACPError.Error[] = [
|
const cases: ACPError.Error[] = [
|
||||||
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
|
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
|
||||||
|
|
@ -27,19 +27,12 @@ describe("acp.error", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("maps auth required to the SDK auth error", () => {
|
test("maps auth required to the SDK auth error", () => {
|
||||||
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError({ providerId: "anthropic" }))
|
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError())
|
||||||
|
|
||||||
expect(requestError).toBeInstanceOf(RequestError)
|
expect(requestError).toBeInstanceOf(RequestError)
|
||||||
expect(requestError.code).toBe(-32000)
|
expect(requestError.code).toBe(-32000)
|
||||||
expect(requestError.message).toBe("Authentication required: provider authentication required")
|
expect(requestError.message).toBe("Authentication required: provider authentication required")
|
||||||
expect(requestError.data).toEqual({ providerId: "anthropic" })
|
expect(requestError.data).toEqual({})
|
||||||
})
|
|
||||||
|
|
||||||
test("maps unsupported operations to method not found", () => {
|
|
||||||
const requestError = ACPError.toRequestError(new ACPError.UnsupportedOperationError({ method: "session/new" }))
|
|
||||||
|
|
||||||
expect(requestError.code).toBe(-32601)
|
|
||||||
expect(requestError.data).toEqual({ method: "session/new" })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("maps service failures to safe internal errors", () => {
|
test("maps service failures to safe internal errors", () => {
|
||||||
|
|
@ -54,7 +47,7 @@ describe("acp.error", () => {
|
||||||
|
|
||||||
test("wraps unknown defects without leaking raw details", () => {
|
test("wraps unknown defects without leaking raw details", () => {
|
||||||
const requestError = ACPError.toRequestError(
|
const requestError = ACPError.toRequestError(
|
||||||
ACPError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
|
ACPError.fromUnknown(new Error("stack has sk-ant-secret and oauth refresh token")),
|
||||||
)
|
)
|
||||||
const serialized = JSON.stringify(requestError.toErrorResponse())
|
const serialized = JSON.stringify(requestError.toErrorResponse())
|
||||||
|
|
||||||
684
packages/cli/test/acp/event-behavior.test.ts
Normal file
684
packages/cli/test/acp/event-behavior.test.ts
Normal file
|
|
@ -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
packages/cli/test/acp/event.test.ts
Normal file
124
packages/cli/test/acp/event.test.ts
Normal 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`))
|
||||||
|
}
|
||||||
|
})
|
||||||
49
packages/cli/test/acp/initialize-auth.subprocess.test.ts
Normal file
49
packages/cli/test/acp/initialize-auth.subprocess.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
85
packages/cli/test/acp/lifecycle.subprocess.test.ts
Normal file
85
packages/cli/test/acp/lifecycle.subprocess.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
499
packages/cli/test/acp/permission-behavior.test.ts
Normal file
499
packages/cli/test/acp/permission-behavior.test.ts
Normal file
|
|
@ -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]] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
62
packages/cli/test/acp/prompt-content.subprocess.test.ts
Normal file
62
packages/cli/test/acp/prompt-content.subprocess.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
269
packages/cli/test/acp/service-directory.test.ts
Normal file
269
packages/cli/test/acp/service-directory.test.ts
Normal file
|
|
@ -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
packages/cli/test/acp/service-fixture.ts
Normal file
207
packages/cli/test/acp/service-fixture.ts
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
232
packages/cli/test/acp/service-lifecycle.test.ts
Normal file
232
packages/cli/test/acp/service-lifecycle.test.ts
Normal file
|
|
@ -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
packages/cli/test/acp/service-usage.test.ts
Normal file
264
packages/cli/test/acp/service-usage.test.ts
Normal 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
packages/cli/test/acp/service.test.ts
Normal file
110
packages/cli/test/acp/service.test.ts
Normal 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" },
|
||||||
|
}
|
||||||
24
packages/cli/test/acp/skills.subprocess.test.ts
Normal file
24
packages/cli/test/acp/skills.subprocess.test.ts
Normal file
|
|
@ -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
packages/cli/test/acp/sse-fixture.ts
Normal file
207
packages/cli/test/acp/sse-fixture.ts
Normal 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
packages/cli/test/acp/subprocess.ts
Normal file
384
packages/cli/test/acp/subprocess.ts
Normal 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`
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import { resolve } from "path"
|
import { resolve } from "node:path"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
completedToolContent,
|
|
||||||
completedToolUpdate,
|
completedToolUpdate,
|
||||||
completedToolRawOutput,
|
errorToolUpdate,
|
||||||
extractImageAttachments,
|
|
||||||
imageContents,
|
|
||||||
pendingToolCall,
|
pendingToolCall,
|
||||||
shellOutputSnapshot,
|
runningToolUpdate,
|
||||||
toLocations,
|
toLocations,
|
||||||
toToolKind,
|
toToolKind,
|
||||||
} from "../../src/acp/tool"
|
} from "../../src/acp/tool"
|
||||||
|
|
||||||
describe("acp tool conversion", () => {
|
describe("acp tools", () => {
|
||||||
test("maps OpenCode tool ids to ACP tool kinds", () => {
|
test("maps OpenCode tool ids to ACP tool kinds", () => {
|
||||||
expect(toToolKind("bash")).toBe("execute")
|
expect(toToolKind("bash")).toBe("execute")
|
||||||
expect(toToolKind("shell")).toBe("execute")
|
expect(toToolKind("shell")).toBe("execute")
|
||||||
|
|
@ -41,7 +38,6 @@ describe("acp tool conversion", () => {
|
||||||
{ path: "/tmp/outside" },
|
{ path: "/tmp/outside" },
|
||||||
])
|
])
|
||||||
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
|
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
|
||||||
// Relative workdir resolves against cwd via the platform path resolver (backslashes on Windows).
|
|
||||||
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
|
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
|
||||||
{ path: resolve("/workspace", "subdir") },
|
{ path: resolve("/workspace", "subdir") },
|
||||||
])
|
])
|
||||||
|
|
@ -50,33 +46,25 @@ describe("acp tool conversion", () => {
|
||||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
|
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("builds completed content with text, edit diffs, and image attachments", () => {
|
test("builds completed content with text and image attachments", () => {
|
||||||
const image = Buffer.from("image-data").toString("base64")
|
const image = Buffer.from("image-data").toString("base64")
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
completedToolContent("edit", {
|
completedToolUpdate({
|
||||||
status: "completed",
|
toolCallId: "tool-1",
|
||||||
|
toolName: "edit",
|
||||||
input: {
|
input: {
|
||||||
filePath: "/tmp/file.ts",
|
filePath: "/tmp/file.ts",
|
||||||
oldString: "before",
|
oldString: "before",
|
||||||
newString: "after",
|
newString: "after",
|
||||||
},
|
},
|
||||||
output: "edited /tmp/file.ts",
|
content: [
|
||||||
attachments: [
|
{ type: "text", text: "edited /tmp/file.ts" },
|
||||||
{
|
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
|
||||||
type: "file",
|
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
|
||||||
mime: "image/png",
|
|
||||||
filename: "image.png",
|
|
||||||
url: `data:image/png;base64,${image}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
filename: "note.txt",
|
|
||||||
url: "data:text/plain;base64,bm90ZQ==",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}),
|
structured: {},
|
||||||
|
}).content,
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
type: "content",
|
type: "content",
|
||||||
|
|
@ -95,16 +83,18 @@ describe("acp tool conversion", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("omits edit diffs until old and new text fields exist", () => {
|
test("omits edit diffs when normalized content does not contain one", () => {
|
||||||
expect(
|
expect(
|
||||||
completedToolContent("write", {
|
completedToolUpdate({
|
||||||
status: "completed",
|
toolCallId: "tool-1",
|
||||||
|
toolName: "write",
|
||||||
input: {
|
input: {
|
||||||
filePath: "/tmp/file.ts",
|
filePath: "/tmp/file.ts",
|
||||||
content: "created",
|
content: "created",
|
||||||
},
|
},
|
||||||
output: "wrote /tmp/file.ts",
|
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
||||||
}),
|
structured: {},
|
||||||
|
}).content,
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
type: "content",
|
type: "content",
|
||||||
|
|
@ -113,6 +103,39 @@ describe("acp tool conversion", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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", () => {
|
test("sends completed tool calls as partial updates", () => {
|
||||||
expect(
|
expect(
|
||||||
pendingToolCall({
|
pendingToolCall({
|
||||||
|
|
@ -127,6 +150,8 @@ describe("acp tool conversion", () => {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
status: "pending",
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
locations: [{ path: "/tmp/file.ts" }],
|
locations: [{ path: "/tmp/file.ts" }],
|
||||||
rawInput: {
|
rawInput: {
|
||||||
|
|
@ -140,15 +165,13 @@ describe("acp tool conversion", () => {
|
||||||
completedToolUpdate({
|
completedToolUpdate({
|
||||||
toolCallId: "tool-1",
|
toolCallId: "tool-1",
|
||||||
toolName: "edit",
|
toolName: "edit",
|
||||||
state: {
|
input: {
|
||||||
status: "completed",
|
filePath: "/tmp/file.ts",
|
||||||
input: {
|
oldString: "before",
|
||||||
filePath: "/tmp/file.ts",
|
newString: "after",
|
||||||
oldString: "before",
|
|
||||||
newString: "after",
|
|
||||||
},
|
|
||||||
output: "Edit applied successfully.",
|
|
||||||
},
|
},
|
||||||
|
content: [{ type: "text", text: "Edit applied successfully." }],
|
||||||
|
structured: { output: "Edit applied successfully." },
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
toolCallId: "tool-1",
|
toolCallId: "tool-1",
|
||||||
|
|
@ -166,123 +189,75 @@ describe("acp tool conversion", () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
rawOutput: {
|
rawOutput: {
|
||||||
output: "Edit applied successfully.",
|
structured: { output: "Edit applied successfully." },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("builds running tool updates with normalized content", () => {
|
||||||
expect(
|
expect(
|
||||||
completedToolUpdate({
|
runningToolUpdate({
|
||||||
toolCallId: "tool-1",
|
toolCallId: "call",
|
||||||
toolName: "edit",
|
toolName: "read",
|
||||||
state: {
|
state: { input: { filePath: "/tmp/a" } },
|
||||||
status: "completed",
|
content: [{ type: "text", text: "done" }],
|
||||||
input: {
|
|
||||||
filePath: "/tmp/file.ts",
|
|
||||||
oldString: "before",
|
|
||||||
newString: "after",
|
|
||||||
},
|
|
||||||
title: "file.ts",
|
|
||||||
output: "Edit applied successfully.",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
toolCallId: "tool-1",
|
toolCallId: "call",
|
||||||
status: "completed",
|
status: "in_progress",
|
||||||
title: "file.ts",
|
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses clean read display text for completed content", () => {
|
test("builds completed raw output with structured data and optional result", () => {
|
||||||
const output = [
|
|
||||||
"<path>/tmp/file.ts</path>",
|
|
||||||
"<type>file</type>",
|
|
||||||
"<content>",
|
|
||||||
"7: first",
|
|
||||||
"8: second",
|
|
||||||
"",
|
|
||||||
"(End of file - total 8 lines)",
|
|
||||||
"</content>",
|
|
||||||
].join("\n")
|
|
||||||
const state = {
|
|
||||||
status: "completed" as const,
|
|
||||||
input: { filePath: "/tmp/file.ts" },
|
|
||||||
output,
|
|
||||||
metadata: {
|
|
||||||
display: {
|
|
||||||
type: "file",
|
|
||||||
path: "/tmp/file.ts",
|
|
||||||
text: "first\nsecond",
|
|
||||||
lineStart: 7,
|
|
||||||
lineEnd: 8,
|
|
||||||
totalLines: 8,
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(completedToolContent("read", state)).toEqual([
|
|
||||||
{
|
|
||||||
type: "content",
|
|
||||||
content: { type: "text", text: "first\nsecond" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(completedToolRawOutput(state)).toEqual({
|
|
||||||
output,
|
|
||||||
metadata: state.metadata,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("builds completed raw output with optional metadata and attachments", () => {
|
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: "image/jpeg",
|
mime: "image/jpeg",
|
||||||
filename: "photo.jpg",
|
name: "photo.jpg",
|
||||||
url: "data:image/jpeg;base64,AAAA",
|
uri: "data:image/jpeg;base64,AAAA",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
completedToolRawOutput({
|
completedToolUpdate({
|
||||||
status: "completed",
|
toolCallId: "call",
|
||||||
|
toolName: "read",
|
||||||
input: {},
|
input: {},
|
||||||
output: "done",
|
content: [],
|
||||||
metadata: { exit: 0 },
|
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||||
attachments,
|
result: "done",
|
||||||
}),
|
}).rawOutput,
|
||||||
).toEqual({
|
).toEqual({
|
||||||
output: "done",
|
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||||
metadata: { exit: 0 },
|
result: "done",
|
||||||
attachments,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
completedToolRawOutput({
|
completedToolUpdate({
|
||||||
status: "completed",
|
toolCallId: "call",
|
||||||
|
toolName: "read",
|
||||||
input: {},
|
input: {},
|
||||||
output: "done",
|
content: [],
|
||||||
}),
|
structured: { output: "done" },
|
||||||
).toEqual({ output: "done" })
|
}).rawOutput,
|
||||||
|
).toEqual({ structured: { output: "done" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extracts image attachments only from data URLs", () => {
|
test("extracts image attachments only from data URLs", () => {
|
||||||
const attachments = [
|
expect(
|
||||||
{
|
completedToolUpdate({
|
||||||
mime: "image/webp",
|
toolCallId: "call",
|
||||||
url: "data:image/webp;charset=utf-8;base64,AAAA",
|
toolName: "read",
|
||||||
},
|
input: {},
|
||||||
{
|
content: [
|
||||||
mime: "image/png",
|
{ type: "file", mime: "image/webp", uri: "data:image/webp;charset=utf-8;base64,AAAA" },
|
||||||
url: "https://example.com/image.png",
|
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
|
||||||
},
|
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
|
||||||
{
|
],
|
||||||
mime: "text/plain",
|
structured: {},
|
||||||
url: "data:text/plain;base64,BBBB",
|
}).content,
|
||||||
},
|
).toEqual([
|
||||||
]
|
|
||||||
|
|
||||||
expect(extractImageAttachments(attachments)).toEqual([{ mimeType: "image/webp", data: "AAAA" }])
|
|
||||||
expect(imageContents(attachments)).toEqual([
|
|
||||||
{
|
{
|
||||||
type: "content",
|
type: "content",
|
||||||
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
|
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
|
||||||
|
|
@ -290,9 +265,28 @@ describe("acp tool conversion", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reads shell output snapshot from string metadata output", () => {
|
test("builds failed tool updates", () => {
|
||||||
expect(shellOutputSnapshot({ metadata: { output: "line 1\nline 2" } })).toBe("line 1\nline 2")
|
expect(
|
||||||
expect(shellOutputSnapshot({ metadata: { output: 42 } })).toBeUndefined()
|
errorToolUpdate({
|
||||||
expect(shellOutputSnapshot({ metadata: undefined })).toBeUndefined()
|
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" },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
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 { Effect } from "effect"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
@ -131,11 +131,16 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||||
const service = yield* Config.Service
|
const service = yield* Config.Service
|
||||||
return yield* service.update((draft) => {
|
return yield* service.update((draft) => {
|
||||||
draft.prompt = { paste: "compact" }
|
draft.prompt = { paste: "compact" }
|
||||||
|
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
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", mono: true },
|
||||||
|
})
|
||||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||||
} finally {
|
} finally {
|
||||||
await Bun.$`rm -rf ${directory}`
|
await Bun.$`rm -rf ${directory}`
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,16 @@ import path from "node:path"
|
||||||
const root = path.resolve(import.meta.dir, "../../..")
|
const root = path.resolve(import.meta.dir, "../../..")
|
||||||
|
|
||||||
describe("CLI frontend import boundaries", () => {
|
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 () => {
|
test("exposes only the intentional package entrypoints", async () => {
|
||||||
const run = await import("@opencode-ai/cli/run")
|
const run = await import("@opencode-ai/cli/run")
|
||||||
const mini = await import("@opencode-ai/tui/mini")
|
const mini = await import("@opencode-ai/tui/mini")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
|
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 path from "node:path"
|
||||||
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
|
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
|
||||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
|
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
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"
|
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
||||||
|
|
||||||
type V2Event = EventSubscribeOutput
|
type V2Event = EventSubscribeOutput
|
||||||
|
|
@ -162,10 +167,13 @@ async function run(input: {
|
||||||
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
|
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
|
||||||
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
|
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||||
renderToolError?: (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 sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
|
const wait = Promise.withResolvers<void>()
|
||||||
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
|
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
|
||||||
while (true) {
|
while (true) {
|
||||||
const value = values.shift()
|
const value = values.shift()
|
||||||
|
|
@ -175,6 +183,7 @@ async function run(input: {
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
|
||||||
yield value
|
yield value
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
@ -193,8 +202,19 @@ async function run(input: {
|
||||||
}) as never,
|
}) as never,
|
||||||
)
|
)
|
||||||
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) 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) => {
|
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
||||||
const messageID = request.id ?? "msg_prompt"
|
const messageID = request.id ?? "msg_prompt"
|
||||||
|
promptID = messageID
|
||||||
values.push(...input.turn(messageID))
|
values.push(...input.turn(messageID))
|
||||||
wake?.()
|
wake?.()
|
||||||
wake = undefined
|
wake = undefined
|
||||||
|
|
@ -244,6 +264,63 @@ afterEach(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("runNonInteractivePrompt", () => {
|
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 () => {
|
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
|
||||||
const sdk = await run({
|
const sdk = await run({
|
||||||
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
|
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
|
||||||
|
|
@ -307,6 +384,9 @@ describe("runNonInteractivePrompt", () => {
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
expect(output.stderr).toBe("")
|
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 () => {
|
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Effect, FileSystem, Scope } from "effect"
|
import { Effect, FileSystem, Scope } from "effect"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Service, type Info } from "@opencode-ai/client/effect/service"
|
import { Service, type Info } from "@opencode-ai/client/effect/service"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||||
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 { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Effect, Schedule, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
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", () => {
|
test("managed service ports are stable per installation channel", () => {
|
||||||
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||||
|
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
|
||||||
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
|
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
|
||||||
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
|
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
|
||||||
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
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("latest")).toBe("service.json")
|
||||||
|
expect(ServiceConfig.filename("next")).toBe("service.json")
|
||||||
expect(ServiceConfig.filename("local")).toBe("service-local.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")).toBe("service-preview-a.json")
|
||||||
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest"))
|
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", "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-1234.2", "preview-a")).toBe(true)
|
||||||
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
|
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
|
||||||
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "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 () => {
|
test("preview registration migration never moves stable discovery", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
|
||||||
const legacy = path.join(root, "service.json")
|
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_DATA_HOME: path.join(root, "data"),
|
||||||
XDG_STATE_HOME: path.join(root, "state"),
|
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 command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
||||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
const port = await availablePort()
|
const port = await availablePort()
|
||||||
|
|
@ -262,20 +247,6 @@ test("concurrent service processes elect one server", async () => {
|
||||||
contender.kill("SIGTERM")
|
contender.kill("SIGTERM")
|
||||||
await contender.exited
|
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 Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||||
await winner?.exited
|
await winner?.exited
|
||||||
expect(await Bun.file(registration).exists()).toBe(false)
|
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)
|
}, 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) {
|
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
|
||||||
for (let attempt = 0; attempt < 400; attempt++) {
|
for (let attempt = 0; attempt < 400; attempt++) {
|
||||||
const value = await Bun.file(file)
|
const value = await Bun.file(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.
|
- 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.
|
- 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.
|
- 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.
|
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
|
||||||
|
|
||||||
## OpenAPI
|
## OpenAPI
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ ultimate source of truth.
|
||||||
- [x] Regular-expression literals.
|
- [x] Regular-expression literals.
|
||||||
- [x] `NaN` and `Infinity` globals.
|
- [x] `NaN` and `Infinity` globals.
|
||||||
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
|
- [ ] 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.
|
- [ ] Tagged-template calls.
|
||||||
- [ ] Getter and setter definitions in object literals.
|
- [ ] Getter and setter definitions in object literals.
|
||||||
|
|
||||||
|
|
@ -69,8 +70,12 @@ ultimate source of truth.
|
||||||
- [x] Unlabeled `break` and `continue`.
|
- [x] Unlabeled `break` and `continue`.
|
||||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||||
- [x] `throw` with arbitrary values.
|
- [x] `throw` with arbitrary values.
|
||||||
- [ ] Labeled statements, labeled `break`, and labeled `continue`.
|
- [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
|
## Functions and callbacks
|
||||||
|
|
||||||
|
|
@ -247,10 +252,15 @@ ultimate source of truth.
|
||||||
|
|
||||||
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
|
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
|
||||||
- [x] Numeric/string indentation for `JSON.stringify`.
|
- [x] Numeric/string indentation for `JSON.stringify`.
|
||||||
|
- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement.
|
||||||
|
Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`.
|
||||||
|
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
|
||||||
|
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
|
||||||
|
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
|
||||||
|
- [x] JSON callbacks retain the blocked-key boundary: parsed or stringified data containing `__proto__`, `constructor`,
|
||||||
|
or `prototype` is rejected before callback traversal.
|
||||||
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
|
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
|
||||||
- [x] Captured `console.dir` and `console.table`.
|
- [x] Captured `console.dir` and `console.table`.
|
||||||
- [ ] `JSON.parse` reviver callbacks.
|
|
||||||
- [ ] `JSON.stringify` function/array replacers.
|
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|
||||||
|
|
@ -295,8 +305,8 @@ ultimate source of truth.
|
||||||
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
|
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
|
||||||
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
|
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
|
||||||
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
|
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
|
||||||
- [ ] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
|
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
|
||||||
`isSupersetOf`, and `isDisjointFrom`.
|
`isSupersetOf`, and `isDisjointFrom`, including supported Set-like operands.
|
||||||
|
|
||||||
## URL and URI helpers
|
## URL and URI helpers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
GlobalNamespace,
|
GlobalNamespace,
|
||||||
IntrinsicReference,
|
IntrinsicReference,
|
||||||
InterpreterRuntimeError,
|
InterpreterRuntimeError,
|
||||||
|
JsonMethodReference,
|
||||||
PromiseCapabilityFunction,
|
PromiseCapabilityFunction,
|
||||||
PromiseNamespace,
|
PromiseNamespace,
|
||||||
UriFunction,
|
UriFunction,
|
||||||
|
|
@ -25,7 +26,6 @@ import {
|
||||||
isCodeModeValue,
|
isCodeModeValue,
|
||||||
} from "../values.js"
|
} from "../values.js"
|
||||||
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
|
||||||
import { invokeMathMethod } from "../stdlib/math.js"
|
import { invokeMathMethod } from "../stdlib/math.js"
|
||||||
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
||||||
import { invokeObjectMethod } from "../stdlib/object.js"
|
import { invokeObjectMethod } from "../stdlib/object.js"
|
||||||
|
|
@ -54,6 +54,7 @@ export type SupportedCallback =
|
||||||
| UriFunction
|
| UriFunction
|
||||||
| PromiseCapabilityFunction
|
| PromiseCapabilityFunction
|
||||||
| GlobalMethodReference
|
| GlobalMethodReference
|
||||||
|
| JsonMethodReference
|
||||||
| IntrinsicReference
|
| IntrinsicReference
|
||||||
| ErrorConstructorReference
|
| ErrorConstructorReference
|
||||||
| GlobalNamespace
|
| GlobalNamespace
|
||||||
|
|
@ -65,6 +66,7 @@ export const isSupportedCallback = (value: unknown): value is SupportedCallback
|
||||||
value instanceof UriFunction ||
|
value instanceof UriFunction ||
|
||||||
value instanceof PromiseCapabilityFunction ||
|
value instanceof PromiseCapabilityFunction ||
|
||||||
value instanceof GlobalMethodReference ||
|
value instanceof GlobalMethodReference ||
|
||||||
|
value instanceof JsonMethodReference ||
|
||||||
value instanceof IntrinsicReference ||
|
value instanceof IntrinsicReference ||
|
||||||
value instanceof ErrorConstructorReference ||
|
value instanceof ErrorConstructorReference ||
|
||||||
// Callable namespaces dispatch like JS: Array/Object/Date/RegExp construct,
|
// Callable namespaces dispatch like JS: Array/Object/Date/RegExp construct,
|
||||||
|
|
@ -103,7 +105,7 @@ export const invokeIntrinsic = <R>(
|
||||||
// Native setters read the current time before argument coercion, whose callbacks may mutate the Date.
|
// Native setters read the current time before argument coercion, whose callbacks may mutate the Date.
|
||||||
const initialTime = target.time
|
const initialTime = target.time
|
||||||
return Effect.map(
|
return Effect.map(
|
||||||
Effect.forEach(args.slice(0, argumentCount), (arg) => coerceDateSetterArgument(runner, arg, node), {
|
Effect.forEach(args.slice(0, argumentCount), (arg) => coerceNumericArgument(runner, arg, node), {
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
}),
|
}),
|
||||||
(values) => invokeDateMethod(target, ref.name, values, node, initialTime),
|
(values) => invokeDateMethod(target, ref.name, values, node, initialTime),
|
||||||
|
|
@ -124,10 +126,10 @@ export const invokeIntrinsic = <R>(
|
||||||
if (ref.receiver instanceof CodeModeURLSearchParams) {
|
if (ref.receiver instanceof CodeModeURLSearchParams) {
|
||||||
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
|
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 coerceDateSetterArgument = <R>(
|
const coerceNumericArgument = <R>(
|
||||||
runner: CallbackRunner<R>,
|
runner: CallbackRunner<R>,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
node: AstNode,
|
node: AstNode,
|
||||||
|
|
@ -155,8 +157,7 @@ const coerceDateSetterArgument = <R>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
if (ref.namespace === "console")
|
if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available.`, node)
|
||||||
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
|
|
||||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
||||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
||||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
||||||
|
|
@ -166,9 +167,9 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
|
||||||
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
|
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
|
||||||
if (ref.namespace === "RegExp") return invokeRegExpStatic(ref.name, args, node)
|
if (ref.namespace === "RegExp") return invokeRegExpStatic(ref.name, args, node)
|
||||||
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
|
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)
|
||||||
}
|
}
|
||||||
return invokeJsonMethod(ref.name, args, node)
|
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
|
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
|
||||||
|
|
@ -346,7 +347,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default:
|
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`)
|
return boundedData(result, `String.${name} result`)
|
||||||
}
|
}
|
||||||
|
|
@ -362,7 +363,7 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||||
case "from":
|
case "from":
|
||||||
return arrayFromItems(args[0], node)
|
return arrayFromItems(args[0], node)
|
||||||
default:
|
default:
|
||||||
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`Array.${name} is not available.`, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,7 +425,7 @@ export const invokeGroupBy = <R>(
|
||||||
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
||||||
}
|
}
|
||||||
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
|
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
|
||||||
const items = groupByItems(source)
|
const items = supportedIterableItems(source)
|
||||||
if (items === undefined) {
|
if (items === undefined) {
|
||||||
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
||||||
}
|
}
|
||||||
|
|
@ -447,7 +448,7 @@ export const invokeGroupBy = <R>(
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = yield* coerceGroupByPropertyKey(runner, yield* apply([item, index]), node)
|
const key = yield* coerceGroupByPropertyKey(runner, yield* apply([item, index]), node)
|
||||||
if (isBlockedMember(key)) {
|
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]
|
const group = result[key]
|
||||||
if (group === undefined) result[key] = [item]
|
if (group === undefined) result[key] = [item]
|
||||||
|
|
@ -458,7 +459,7 @@ export const invokeGroupBy = <R>(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupByItems = (source: unknown): Iterable<unknown> | undefined => {
|
const supportedIterableItems = (source: unknown): Iterable<unknown> | undefined => {
|
||||||
if (Array.isArray(source) || typeof source === "string") return source
|
if (Array.isArray(source) || typeof source === "string") return source
|
||||||
if (source instanceof CodeModeMap) return source.map.entries()
|
if (source instanceof CodeModeMap) return source.map.entries()
|
||||||
if (source instanceof CodeModeSet) return source.set.values()
|
if (source instanceof CodeModeSet) return source.set.values()
|
||||||
|
|
@ -617,7 +618,7 @@ const invokeMapMethod = <R>(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`Map method '${name}' is not available.`, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,11 +656,140 @@ const invokeSetMethod = <R>(
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
case "union":
|
||||||
|
case "intersection":
|
||||||
|
case "difference":
|
||||||
|
case "symmetricDifference":
|
||||||
|
case "isSubsetOf":
|
||||||
|
case "isSupersetOf":
|
||||||
|
case "isDisjointFrom":
|
||||||
|
return invokeSetOperation(runner, target, name, args[0], node)
|
||||||
default:
|
default:
|
||||||
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`Set method '${name}' is not available.`, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invokeSetOperation = <R>(
|
||||||
|
runner: CallbackRunner<R>,
|
||||||
|
target: CodeModeSet,
|
||||||
|
name: string,
|
||||||
|
source: unknown,
|
||||||
|
node: AstNode,
|
||||||
|
): Effect.Effect<unknown, unknown, R> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const other = yield* loadSetRecord(runner, source, name, node)
|
||||||
|
if (name === "union") {
|
||||||
|
const result = copySet(target)
|
||||||
|
for (const item of yield* other.keys()) result.set.add(item)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (name === "intersection") {
|
||||||
|
const result = new CodeModeSet()
|
||||||
|
if (target.set.size <= other.size) {
|
||||||
|
for (const item of target.set.values()) {
|
||||||
|
if (yield* other.has(item)) result.set.add(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for (const item of yield* other.keys()) {
|
||||||
|
if (target.set.has(item)) result.set.add(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (name === "difference") {
|
||||||
|
const result = copySet(target)
|
||||||
|
if (target.set.size <= other.size) {
|
||||||
|
for (const item of result.set.values()) {
|
||||||
|
if (yield* other.has(item)) result.set.delete(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for (const item of yield* other.keys()) result.set.delete(item)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (name === "symmetricDifference") {
|
||||||
|
const result = copySet(target)
|
||||||
|
for (const item of yield* other.keys()) {
|
||||||
|
if (target.set.has(item)) result.set.delete(item)
|
||||||
|
else result.set.add(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (name === "isSubsetOf") {
|
||||||
|
if (target.set.size > other.size) return false
|
||||||
|
for (const item of target.set.values()) {
|
||||||
|
if (!(yield* other.has(item))) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (name === "isSupersetOf") {
|
||||||
|
if (target.set.size < other.size) return false
|
||||||
|
for (const item of yield* other.keys()) {
|
||||||
|
if (!target.set.has(item)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (target.set.size <= other.size) {
|
||||||
|
for (const item of target.set.values()) {
|
||||||
|
if (yield* other.has(item)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for (const item of yield* other.keys()) {
|
||||||
|
if (target.set.has(item)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const copySet = (source: CodeModeSet): CodeModeSet => {
|
||||||
|
const result = new CodeModeSet()
|
||||||
|
for (const item of source.set.values()) result.set.add(item)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: string, node: AstNode) => {
|
||||||
|
if (source instanceof CodeModeSet) {
|
||||||
|
return Effect.succeed({
|
||||||
|
size: source.set.size,
|
||||||
|
has: (item: unknown) => Effect.succeed(source.set.has(item)),
|
||||||
|
keys: () => Effect.succeed(source.set.values()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (source instanceof CodeModeMap) {
|
||||||
|
return Effect.succeed({
|
||||||
|
size: source.map.size,
|
||||||
|
has: (item: unknown) => Effect.succeed(source.map.has(item)),
|
||||||
|
keys: () => Effect.succeed(source.map.keys()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (source === null || typeof source !== "object" || isCodeModeValue(source)) {
|
||||||
|
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError")
|
||||||
|
}
|
||||||
|
const object = source as Record<string, unknown>
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const size = yield* coerceNumericArgument(runner, object.size, node)
|
||||||
|
if (Number.isNaN(size)) {
|
||||||
|
throw new InterpreterRuntimeError(`Set.${name} received a Set-like object with an invalid size.`, node).as(
|
||||||
|
"TypeError",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!isSupportedCallback(object.has) || !isSupportedCallback(object.keys)) {
|
||||||
|
throw new InterpreterRuntimeError(`Set.${name} expects callable 'has' and 'keys' methods.`, node).as("TypeError")
|
||||||
|
}
|
||||||
|
const has = object.has
|
||||||
|
const keys = object.keys
|
||||||
|
return {
|
||||||
|
size: Math.max(Math.trunc(size), 0),
|
||||||
|
has: (item: unknown) => Effect.map(runner.invokeCallable(has, [item], node), Boolean),
|
||||||
|
keys: () =>
|
||||||
|
Effect.flatMap(runner.invokeCallable(keys, [], node), (result) => {
|
||||||
|
if (Array.isArray(result)) return Effect.succeed(result)
|
||||||
|
throw new InterpreterRuntimeError(`Set.${name} expected 'keys' to return an iterator.`, node).as("TypeError")
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const invokeURLSearchParamsMethod = <R>(
|
const invokeURLSearchParamsMethod = <R>(
|
||||||
runner: CallbackRunner<R>,
|
runner: CallbackRunner<R>,
|
||||||
target: CodeModeURLSearchParams,
|
target: CodeModeURLSearchParams,
|
||||||
|
|
@ -730,7 +860,7 @@ const invokeURLSearchParamsMethod = <R>(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available.`, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -973,7 +1103,7 @@ const invokeArrayMethod = <R>(
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`Array method '${name}' is not available.`, node)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,12 @@ export type Binding = {
|
||||||
export type StatementResult =
|
export type StatementResult =
|
||||||
| { kind: "none" }
|
| { kind: "none" }
|
||||||
| { kind: "return"; value: unknown }
|
| { kind: "return"; value: unknown }
|
||||||
| { kind: "break" }
|
| { kind: "break"; label?: string }
|
||||||
| { kind: "continue" }
|
| { kind: "continue"; label?: string }
|
||||||
|
|
||||||
export type MemberReference = {
|
export type MemberReference = {
|
||||||
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
|
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
|
||||||
key: string | number
|
key: PropertyKey
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CodeModeFunction {
|
export class CodeModeFunction {
|
||||||
|
|
@ -61,6 +61,12 @@ export class ComputedValue {
|
||||||
|
|
||||||
export class PromiseNamespace {}
|
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 type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
|
||||||
|
|
||||||
export class PromiseMethodReference {
|
export class PromiseMethodReference {
|
||||||
|
|
@ -99,11 +105,15 @@ export class GlobalNamespace {
|
||||||
|
|
||||||
export class GlobalMethodReference {
|
export class GlobalMethodReference {
|
||||||
constructor(
|
constructor(
|
||||||
readonly namespace: GlobalNamespaceName | "Number" | "String",
|
readonly namespace: Exclude<GlobalNamespaceName, "JSON"> | "Number" | "String",
|
||||||
readonly name: string,
|
readonly name: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class JsonMethodReference {
|
||||||
|
constructor(readonly name: "parse" | "stringify") {}
|
||||||
|
}
|
||||||
|
|
||||||
export class CoercionFunction {
|
export class CoercionFunction {
|
||||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
|
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +172,7 @@ export class InterpreterRuntimeError extends Error {
|
||||||
|
|
||||||
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
|
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
|
||||||
new InterpreterRuntimeError(
|
new InterpreterRuntimeError(
|
||||||
`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`,
|
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
|
||||||
node,
|
node,
|
||||||
"UnsupportedSyntax",
|
"UnsupportedSyntax",
|
||||||
[supportedSyntaxMessage],
|
[supportedSyntaxMessage],
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ import {
|
||||||
GlobalNamespace,
|
GlobalNamespace,
|
||||||
InterpreterRuntimeError,
|
InterpreterRuntimeError,
|
||||||
IntrinsicReference,
|
IntrinsicReference,
|
||||||
|
JsonMethodReference,
|
||||||
PromiseCapabilityFunction,
|
PromiseCapabilityFunction,
|
||||||
PromiseInstanceMethodReference,
|
PromiseInstanceMethodReference,
|
||||||
PromiseMethodReference,
|
PromiseMethodReference,
|
||||||
PromiseNamespace,
|
PromiseNamespace,
|
||||||
SearchFunction,
|
SearchFunction,
|
||||||
|
SymbolNamespace,
|
||||||
UriFunction,
|
UriFunction,
|
||||||
} from "./model.js"
|
} from "./model.js"
|
||||||
import { ToolReference } from "../tool-runtime.js"
|
import { ToolReference } from "../tool-runtime.js"
|
||||||
|
|
@ -23,6 +25,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||||
value instanceof IntrinsicReference ||
|
value instanceof IntrinsicReference ||
|
||||||
value instanceof GlobalNamespace ||
|
value instanceof GlobalNamespace ||
|
||||||
value instanceof GlobalMethodReference ||
|
value instanceof GlobalMethodReference ||
|
||||||
|
value instanceof JsonMethodReference ||
|
||||||
value instanceof PromiseNamespace ||
|
value instanceof PromiseNamespace ||
|
||||||
value instanceof PromiseMethodReference ||
|
value instanceof PromiseMethodReference ||
|
||||||
value instanceof PromiseInstanceMethodReference ||
|
value instanceof PromiseInstanceMethodReference ||
|
||||||
|
|
@ -32,6 +35,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||||
value instanceof SearchFunction ||
|
value instanceof SearchFunction ||
|
||||||
value instanceof PromiseCapabilityFunction ||
|
value instanceof PromiseCapabilityFunction ||
|
||||||
value instanceof ErrorConstructorReference ||
|
value instanceof ErrorConstructorReference ||
|
||||||
|
value instanceof SymbolNamespace ||
|
||||||
isCodeModeValue(value)
|
isCodeModeValue(value)
|
||||||
|
|
||||||
function* childValues(value: object): Generator<unknown> {
|
function* childValues(value: object): Generator<unknown> {
|
||||||
|
|
@ -82,12 +86,7 @@ export const containsOpaqueReference = (value: unknown): boolean => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject cycles before mutation so later boundary walks remain safe.
|
// Reject cycles before mutation so later boundary walks remain safe.
|
||||||
export const rejectCircularInsertion = (
|
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
|
||||||
container: object,
|
|
||||||
value: unknown,
|
|
||||||
label: string,
|
|
||||||
node: AstNode,
|
|
||||||
): void => {
|
|
||||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||||
const seen = new Set<object>()
|
const seen = new Set<object>()
|
||||||
while (pending.length > 0) {
|
while (pending.length > 0) {
|
||||||
|
|
@ -111,11 +110,13 @@ export const typeofValue = (value: unknown): string => {
|
||||||
value instanceof CoercionFunction ||
|
value instanceof CoercionFunction ||
|
||||||
value instanceof IntrinsicReference ||
|
value instanceof IntrinsicReference ||
|
||||||
value instanceof GlobalMethodReference ||
|
value instanceof GlobalMethodReference ||
|
||||||
|
value instanceof JsonMethodReference ||
|
||||||
value instanceof PromiseMethodReference ||
|
value instanceof PromiseMethodReference ||
|
||||||
value instanceof PromiseInstanceMethodReference ||
|
value instanceof PromiseInstanceMethodReference ||
|
||||||
value instanceof PromiseNamespace ||
|
value instanceof PromiseNamespace ||
|
||||||
value instanceof PromiseCapabilityFunction ||
|
value instanceof PromiseCapabilityFunction ||
|
||||||
value instanceof ErrorConstructorReference
|
value instanceof ErrorConstructorReference ||
|
||||||
|
value instanceof SymbolNamespace
|
||||||
)
|
)
|
||||||
return "function"
|
return "function"
|
||||||
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
|
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
|
||||||
|
|
|
||||||
|
|
@ -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 { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
||||||
import {
|
import {
|
||||||
type AstNode,
|
type AstNode,
|
||||||
|
AsyncIteratorSymbol,
|
||||||
asNode,
|
asNode,
|
||||||
type Binding,
|
type Binding,
|
||||||
CodeModeFunction,
|
CodeModeFunction,
|
||||||
|
|
@ -19,6 +20,9 @@ import {
|
||||||
IntrinsicReference,
|
IntrinsicReference,
|
||||||
InterpreterRuntimeError,
|
InterpreterRuntimeError,
|
||||||
isRecord,
|
isRecord,
|
||||||
|
IteratorSymbol,
|
||||||
|
IteratorSymbols,
|
||||||
|
JsonMethodReference,
|
||||||
type MemberReference,
|
type MemberReference,
|
||||||
OptionalShortCircuit,
|
OptionalShortCircuit,
|
||||||
PromiseCapabilityFunction,
|
PromiseCapabilityFunction,
|
||||||
|
|
@ -29,6 +33,7 @@ import {
|
||||||
ProgramThrow,
|
ProgramThrow,
|
||||||
type ProgramNode,
|
type ProgramNode,
|
||||||
SearchFunction,
|
SearchFunction,
|
||||||
|
SymbolNamespace,
|
||||||
type StatementResult,
|
type StatementResult,
|
||||||
supportedSyntaxMessage,
|
supportedSyntaxMessage,
|
||||||
unsupportedSyntax,
|
unsupportedSyntax,
|
||||||
|
|
@ -55,7 +60,7 @@ import { ScopeStack } from "./scope.js"
|
||||||
import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js"
|
import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js"
|
||||||
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
||||||
import { dateMethods, dateStatics } from "../stdlib/date.js"
|
import { dateMethods, dateStatics } from "../stdlib/date.js"
|
||||||
import { jsonStatics } from "../stdlib/json.js"
|
import { invokeJsonMethod, jsonStatics, type JsonMethodName } from "../stdlib/json.js"
|
||||||
import { mathConstants, mathMethods } from "../stdlib/math.js"
|
import { mathConstants, mathMethods } from "../stdlib/math.js"
|
||||||
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
||||||
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
|
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
|
||||||
|
|
@ -102,7 +107,6 @@ import {
|
||||||
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
||||||
Object: objectStatics,
|
Object: objectStatics,
|
||||||
Math: mathMethods,
|
Math: mathMethods,
|
||||||
JSON: jsonStatics,
|
|
||||||
Array: arrayStatics,
|
Array: arrayStatics,
|
||||||
console: consoleMethods,
|
console: consoleMethods,
|
||||||
Date: dateStatics,
|
Date: dateStatics,
|
||||||
|
|
@ -166,7 +170,7 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(
|
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,
|
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> {
|
export class Interpreter<R> {
|
||||||
private scopes: ScopeStack
|
private scopes: ScopeStack
|
||||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
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("tools", { mutable: false, value: new ToolReference([]) })
|
||||||
globalScope.set("search", { mutable: false, value: new SearchFunction() })
|
globalScope.set("search", { mutable: false, value: new SearchFunction() })
|
||||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
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("undefined", { mutable: false, value: undefined })
|
||||||
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
|
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
|
||||||
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
|
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
|
||||||
|
|
@ -341,6 +352,8 @@ export class Interpreter<R> {
|
||||||
return this.evaluateIfStatement(node)
|
return this.evaluateIfStatement(node)
|
||||||
case "SwitchStatement":
|
case "SwitchStatement":
|
||||||
return this.evaluateSwitchStatement(node)
|
return this.evaluateSwitchStatement(node)
|
||||||
|
case "LabeledStatement":
|
||||||
|
return this.evaluateLabeledStatement(node)
|
||||||
case "WhileStatement":
|
case "WhileStatement":
|
||||||
return this.evaluateWhileStatement(node)
|
return this.evaluateWhileStatement(node)
|
||||||
case "DoWhileStatement":
|
case "DoWhileStatement":
|
||||||
|
|
@ -391,12 +404,9 @@ export class Interpreter<R> {
|
||||||
|
|
||||||
private createFunction(node: AstNode): CodeModeFunction {
|
private createFunction(node: AstNode): CodeModeFunction {
|
||||||
if (node.generator === true) {
|
if (node.generator === true) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError("Generator functions are not supported.", node, "UnsupportedSyntax", [
|
||||||
"Generator functions are not supported in CodeMode.",
|
supportedSyntaxMessage,
|
||||||
node,
|
])
|
||||||
"UnsupportedSyntax",
|
|
||||||
[supportedSyntaxMessage],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return new CodeModeFunction(
|
return new CodeModeFunction(
|
||||||
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
|
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
|
||||||
|
|
@ -452,11 +462,7 @@ export class Interpreter<R> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
|
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
|
||||||
if (containsOpaqueReference(discriminant)) {
|
if (containsOpaqueReference(discriminant)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError("Switch discriminants must be data values.", node, "InvalidDataValue")
|
||||||
"Switch discriminants must be data values in CodeMode.",
|
|
||||||
node,
|
|
||||||
"InvalidDataValue",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
self.scopes.push()
|
self.scopes.push()
|
||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
|
|
@ -472,11 +478,7 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
const candidate = yield* self.evaluateExpression(test)
|
const candidate = yield* self.evaluateExpression(test)
|
||||||
if (containsOpaqueReference(candidate)) {
|
if (containsOpaqueReference(candidate)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError("Switch case values must be data values.", test, "InvalidDataValue")
|
||||||
"Switch case values must be data values in CodeMode.",
|
|
||||||
test,
|
|
||||||
"InvalidDataValue",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (candidate === discriminant) {
|
if (candidate === discriminant) {
|
||||||
selected = index
|
selected = index
|
||||||
|
|
@ -488,7 +490,10 @@ export class Interpreter<R> {
|
||||||
for (let index = start; index < cases.length; index += 1) {
|
for (let index = start; index < cases.length; index += 1) {
|
||||||
for (const statementValue of getArray(cases[index]!, "consequent")) {
|
for (const statementValue of getArray(cases[index]!, "consequent")) {
|
||||||
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
|
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
|
||||||
if (result.kind === "break") return { kind: "none" } satisfies StatementResult
|
if (result.kind === "break") {
|
||||||
|
if (result.label === undefined) return { kind: "none" } satisfies StatementResult
|
||||||
|
return result
|
||||||
|
}
|
||||||
if (result.kind === "return" || result.kind === "continue") return result
|
if (result.kind === "return" || result.kind === "continue") return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -497,7 +502,10 @@ export class Interpreter<R> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateWhileStatement(
|
||||||
|
node: AstNode,
|
||||||
|
labels?: ReadonlySet<string>,
|
||||||
|
): Effect.Effect<StatementResult, unknown, R> {
|
||||||
const testNode = getNode(node, "test")
|
const testNode = getNode(node, "test")
|
||||||
const bodyNode = getNode(node, "body")
|
const bodyNode = getNode(node, "body")
|
||||||
|
|
||||||
|
|
@ -507,10 +515,12 @@ export class Interpreter<R> {
|
||||||
const result = yield* self.evaluateStatement(bodyNode)
|
const result = yield* self.evaluateStatement(bodyNode)
|
||||||
|
|
||||||
if (result.kind === "continue") {
|
if (result.kind === "continue") {
|
||||||
|
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "break") {
|
if (result.kind === "break") {
|
||||||
|
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" } satisfies StatementResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,7 +533,10 @@ export class Interpreter<R> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateDoWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateDoWhileStatement(
|
||||||
|
node: AstNode,
|
||||||
|
labels?: ReadonlySet<string>,
|
||||||
|
): Effect.Effect<StatementResult, unknown, R> {
|
||||||
const bodyNode = getNode(node, "body")
|
const bodyNode = getNode(node, "body")
|
||||||
const testNode = getNode(node, "test")
|
const testNode = getNode(node, "test")
|
||||||
|
|
||||||
|
|
@ -533,10 +546,12 @@ export class Interpreter<R> {
|
||||||
const result = yield* self.evaluateStatement(bodyNode)
|
const result = yield* self.evaluateStatement(bodyNode)
|
||||||
|
|
||||||
if (result.kind === "continue") {
|
if (result.kind === "continue") {
|
||||||
|
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "break") {
|
if (result.kind === "break") {
|
||||||
|
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" } satisfies StatementResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -549,7 +564,10 @@ export class Interpreter<R> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateForStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateForStatement(
|
||||||
|
node: AstNode,
|
||||||
|
labels?: ReadonlySet<string>,
|
||||||
|
): Effect.Effect<StatementResult, unknown, R> {
|
||||||
this.scopes.push()
|
this.scopes.push()
|
||||||
const self = this
|
const self = this
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|
@ -593,9 +611,12 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "break") {
|
if (result.kind === "break") {
|
||||||
|
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" } satisfies StatementResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
|
|
||||||
nextIteration()
|
nextIteration()
|
||||||
if (updateNode) {
|
if (updateNode) {
|
||||||
yield* self.evaluateExpression(updateNode)
|
yield* self.evaluateExpression(updateNode)
|
||||||
|
|
@ -610,11 +631,11 @@ export class Interpreter<R> {
|
||||||
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
|
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateForOfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateForOfStatement(
|
||||||
if (getBoolean(node, "await")) {
|
node: AstNode,
|
||||||
throw new InterpreterRuntimeError("for await...of is not supported.", node)
|
labels?: ReadonlySet<string>,
|
||||||
}
|
): Effect.Effect<StatementResult, unknown, R> {
|
||||||
|
const awaiting = getBoolean(node, "await")
|
||||||
const left = getNode(node, "left")
|
const left = getNode(node, "left")
|
||||||
const declared = loopDeclaration(left, "for...of")
|
const declared = loopDeclaration(left, "for...of")
|
||||||
if (declared?.lexical) this.scopes.push()
|
if (declared?.lexical) this.scopes.push()
|
||||||
|
|
@ -626,8 +647,12 @@ export class Interpreter<R> {
|
||||||
const body = getNode(node, "body")
|
const body = getNode(node, "body")
|
||||||
|
|
||||||
const iterable = spreadItems(right)
|
const iterable = spreadItems(right)
|
||||||
if (iterable === undefined) {
|
const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined
|
||||||
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
|
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
|
let assignment: AstNode | undefined
|
||||||
|
|
@ -644,8 +669,8 @@ export class Interpreter<R> {
|
||||||
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const value of iterable) {
|
const evaluateBody = (value: unknown) =>
|
||||||
const result = yield* Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (declared) {
|
if (declared) {
|
||||||
self.scopes.push()
|
self.scopes.push()
|
||||||
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||||
|
|
@ -662,20 +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") {
|
if (result.kind === "return") {
|
||||||
|
yield* self.closeIterator(iterator, node)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "break") {
|
if (result.kind === "break") {
|
||||||
|
yield* self.closeIterator(iterator, node)
|
||||||
|
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" } satisfies StatementResult
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "continue") {
|
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
|
||||||
continue
|
yield* self.closeIterator(iterator, node)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: "none" } satisfies StatementResult
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.ensuring(
|
Effect.ensuring(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
|
@ -685,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 {
|
private enumerableKeys(value: unknown): Array<string> | undefined {
|
||||||
if (value instanceof ToolReference) {
|
if (value instanceof ToolReference) {
|
||||||
return [...this.toolKeys(value.path)]
|
return [...this.toolKeys(value.path)]
|
||||||
|
|
@ -698,7 +846,10 @@ export class Interpreter<R> {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateForInStatement(
|
||||||
|
node: AstNode,
|
||||||
|
labels?: ReadonlySet<string>,
|
||||||
|
): Effect.Effect<StatementResult, unknown, R> {
|
||||||
const left = getNode(node, "left")
|
const left = getNode(node, "left")
|
||||||
const declared = loopDeclaration(left, "for...in")
|
const declared = loopDeclaration(left, "for...in")
|
||||||
if (declared?.lexical) this.scopes.push()
|
if (declared?.lexical) this.scopes.push()
|
||||||
|
|
@ -712,7 +863,7 @@ export class Interpreter<R> {
|
||||||
const keys = self.enumerableKeys(right)
|
const keys = self.enumerableKeys(right)
|
||||||
if (keys === undefined) {
|
if (keys === undefined) {
|
||||||
throw new InterpreterRuntimeError(
|
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,
|
node,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -748,10 +899,12 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "break") {
|
if (result.kind === "break") {
|
||||||
|
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" } satisfies StatementResult
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "continue") {
|
if (result.kind === "continue") {
|
||||||
|
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -768,22 +921,36 @@ export class Interpreter<R> {
|
||||||
|
|
||||||
private evaluateBreakStatement(node: AstNode): StatementResult {
|
private evaluateBreakStatement(node: AstNode): StatementResult {
|
||||||
const labelNode = getOptionalNode(node, "label")
|
const labelNode = getOptionalNode(node, "label")
|
||||||
|
return labelNode ? { kind: "break", label: getString(labelNode, "name") } : { kind: "break" }
|
||||||
if (labelNode) {
|
|
||||||
throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { kind: "break" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateContinueStatement(node: AstNode): StatementResult {
|
private evaluateContinueStatement(node: AstNode): StatementResult {
|
||||||
const labelNode = getOptionalNode(node, "label")
|
const labelNode = getOptionalNode(node, "label")
|
||||||
|
return labelNode ? { kind: "continue", label: getString(labelNode, "name") } : { kind: "continue" }
|
||||||
|
}
|
||||||
|
|
||||||
if (labelNode) {
|
private evaluateLabeledStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||||
throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node)
|
const labels = new Set<string>()
|
||||||
|
let body = node
|
||||||
|
while (body.type === "LabeledStatement") {
|
||||||
|
labels.add(getString(getNode(body, "label"), "name"))
|
||||||
|
body = getNode(body, "body")
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: "continue" }
|
const evaluated = (() => {
|
||||||
|
if (body.type === "WhileStatement") return this.evaluateWhileStatement(body, labels)
|
||||||
|
if (body.type === "DoWhileStatement") return this.evaluateDoWhileStatement(body, labels)
|
||||||
|
if (body.type === "ForStatement") return this.evaluateForStatement(body, labels)
|
||||||
|
if (body.type === "ForOfStatement") return this.evaluateForOfStatement(body, labels)
|
||||||
|
if (body.type === "ForInStatement") return this.evaluateForInStatement(body, labels)
|
||||||
|
return this.evaluateStatement(body)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return Effect.map(evaluated, (result) =>
|
||||||
|
result.kind === "break" && result.label !== undefined && labels.has(result.label)
|
||||||
|
? ({ kind: "none" } satisfies StatementResult)
|
||||||
|
: result,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateThrowStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateThrowStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||||
|
|
@ -883,7 +1050,7 @@ export class Interpreter<R> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumed = new Set<string>()
|
const consumed = new Set<PropertyKey>()
|
||||||
for (const propertyValue of getArray(pattern, "properties")) {
|
for (const propertyValue of getArray(pattern, "properties")) {
|
||||||
const property = asNode(propertyValue, "properties")
|
const property = asNode(propertyValue, "properties")
|
||||||
|
|
||||||
|
|
@ -892,15 +1059,19 @@ export class Interpreter<R> {
|
||||||
for (const [key, item] of Object.entries(value as SafeObject)) {
|
for (const [key, item] of Object.entries(value as SafeObject)) {
|
||||||
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
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)
|
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = yield* self.destructuringPropertyKey(property)
|
const key = yield* self.destructuringPropertyKey(property)
|
||||||
if (isBlockedMember(String(key))) {
|
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(
|
yield* self.declarePattern(
|
||||||
getNode(property, "value"),
|
getNode(property, "value"),
|
||||||
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
|
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
|
||||||
|
|
@ -963,7 +1134,7 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = value as SafeObject | Array<unknown>
|
const source = value as SafeObject | Array<unknown>
|
||||||
const consumed = new Set<string>()
|
const consumed = new Set<PropertyKey>()
|
||||||
for (const propertyValue of getArray(pattern, "properties")) {
|
for (const propertyValue of getArray(pattern, "properties")) {
|
||||||
const property = asNode(propertyValue, "properties")
|
const property = asNode(propertyValue, "properties")
|
||||||
if (property.type === "RestElement") {
|
if (property.type === "RestElement") {
|
||||||
|
|
@ -971,14 +1142,18 @@ export class Interpreter<R> {
|
||||||
for (const [key, item] of Object.entries(source)) {
|
for (const [key, item] of Object.entries(source)) {
|
||||||
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
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)
|
yield* self.assignPattern(getNode(property, "argument"), rest, property)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const key = yield* self.destructuringPropertyKey(property)
|
const key = yield* self.destructuringPropertyKey(property)
|
||||||
if (isBlockedMember(String(key))) {
|
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)
|
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -1005,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") {
|
if (property.type !== "Property" || getString(property, "kind") !== "init") {
|
||||||
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
|
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
|
||||||
}
|
}
|
||||||
|
|
@ -1016,12 +1191,12 @@ export class Interpreter<R> {
|
||||||
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
|
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
|
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
|
||||||
if (!Array.isArray(source)) return source[String(key)]
|
if (!Array.isArray(source)) return Reflect.get(source, key)
|
||||||
if (key === "length") return source.length
|
if (key === "length") return source.length
|
||||||
if (typeof key === "number") return source[key]
|
if (typeof key === "number") return source[key]
|
||||||
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
|
if (Object.hasOwn(source, key)) return Reflect.get(source, key)
|
||||||
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
|
if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1152,7 +1327,7 @@ export class Interpreter<R> {
|
||||||
if (first === null || first === undefined) return {}
|
if (first === null || first === undefined) return {}
|
||||||
if (typeof first === "object") return first
|
if (typeof first === "object") return first
|
||||||
throw new InterpreterRuntimeError(
|
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,
|
node,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1329,7 +1504,7 @@ export class Interpreter<R> {
|
||||||
|
|
||||||
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
|
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
|
||||||
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
|
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.
|
// 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.
|
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
|
||||||
|
|
@ -1420,7 +1595,7 @@ export class Interpreter<R> {
|
||||||
if (operator === "!") return !value
|
if (operator === "!") return !value
|
||||||
if (operator === "void") return undefined
|
if (operator === "void") return undefined
|
||||||
if (containsOpaqueReference(value)) {
|
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 =
|
const operand =
|
||||||
value instanceof CodeModeDate
|
value instanceof CodeModeDate
|
||||||
|
|
@ -1535,11 +1710,7 @@ export class Interpreter<R> {
|
||||||
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
|
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
|
||||||
const operand = (current: unknown): number => {
|
const operand = (current: unknown): number => {
|
||||||
if (containsOpaqueReference(current)) {
|
if (containsOpaqueReference(current)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(`'${operator}' requires a data value.`, argument, "InvalidDataValue")
|
||||||
`'${operator}' requires a data value in CodeMode.`,
|
|
||||||
argument,
|
|
||||||
"InvalidDataValue",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return coerceToNumber(current)
|
return coerceToNumber(current)
|
||||||
}
|
}
|
||||||
|
|
@ -1624,6 +1795,9 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
||||||
}
|
}
|
||||||
|
if (callable instanceof JsonMethodReference) {
|
||||||
|
return yield* invokeJsonMethod(self.runner, callable.name, args, node)
|
||||||
|
}
|
||||||
if (callable instanceof CoercionFunction) {
|
if (callable instanceof CoercionFunction) {
|
||||||
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
|
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
|
||||||
}
|
}
|
||||||
|
|
@ -1652,6 +1826,12 @@ export class Interpreter<R> {
|
||||||
if (callable instanceof PromiseNamespace) {
|
if (callable instanceof PromiseNamespace) {
|
||||||
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
|
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) {
|
if (callable instanceof PromiseCapabilityFunction) {
|
||||||
callable.settle(args[0])
|
callable.settle(args[0])
|
||||||
return undefined
|
return undefined
|
||||||
|
|
@ -1659,7 +1839,7 @@ export class Interpreter<R> {
|
||||||
if (callable === undefined || callable === null) {
|
if (callable === undefined || callable === null) {
|
||||||
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee).as("TypeError")
|
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1675,8 +1855,7 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeConsole(name: string, args: Array<unknown>, node: AstNode): undefined {
|
private invokeConsole(name: string, args: Array<unknown>, node: AstNode): undefined {
|
||||||
if (!consoleMethods.has(name))
|
if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available.`, node)
|
||||||
throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node)
|
|
||||||
this.logs.push(formatConsoleMessage(name, args))
|
this.logs.push(formatConsoleMessage(name, args))
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
@ -1691,10 +1870,7 @@ export class Interpreter<R> {
|
||||||
const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
|
const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
|
||||||
const items = spreadItems(spread)
|
const items = spreadItems(spread)
|
||||||
if (items === undefined)
|
if (items === undefined)
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError("Spread arguments require an array, string, Map, or Set.", argNode)
|
||||||
"Spread arguments require an array, string, Map, or Set in CodeMode.",
|
|
||||||
argNode,
|
|
||||||
)
|
|
||||||
args.push(...items)
|
args.push(...items)
|
||||||
} else {
|
} else {
|
||||||
args.push(yield* self.evaluateExpression(argNode))
|
args.push(yield* self.evaluateExpression(argNode))
|
||||||
|
|
@ -1760,17 +1936,15 @@ export class Interpreter<R> {
|
||||||
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
||||||
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
|
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
|
||||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
|
||||||
"Object spread requires a data object in CodeMode.",
|
|
||||||
property,
|
|
||||||
"InvalidDataValue",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(spread)) {
|
for (const [key, value] of Object.entries(spread)) {
|
||||||
if (isBlockedMember(key))
|
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
|
||||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property)
|
|
||||||
objectValue[key] = value
|
objectValue[key] = value
|
||||||
}
|
}
|
||||||
|
for (const symbol of IteratorSymbols) {
|
||||||
|
if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol))
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1799,9 +1973,9 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlockedMember(String(key))) {
|
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
|
return objectValue
|
||||||
|
|
@ -1825,10 +1999,7 @@ export class Interpreter<R> {
|
||||||
const spread = yield* self.evaluateExpression(getNode(element, "argument"))
|
const spread = yield* self.evaluateExpression(getNode(element, "argument"))
|
||||||
const items = spreadItems(spread)
|
const items = spreadItems(spread)
|
||||||
if (items === undefined)
|
if (items === undefined)
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError("Array spread requires an array, string, Map, or Set.", element)
|
||||||
"Array spread requires an array, string, Map, or Set in CodeMode.",
|
|
||||||
element,
|
|
||||||
)
|
|
||||||
values.push(...items)
|
values.push(...items)
|
||||||
} else {
|
} else {
|
||||||
values.push(yield* self.evaluateExpression(element))
|
values.push(yield* self.evaluateExpression(element))
|
||||||
|
|
@ -1889,6 +2060,7 @@ export class Interpreter<R> {
|
||||||
| PromiseInstanceMethodReference
|
| PromiseInstanceMethodReference
|
||||||
| IntrinsicReference
|
| IntrinsicReference
|
||||||
| GlobalMethodReference
|
| GlobalMethodReference
|
||||||
|
| JsonMethodReference
|
||||||
| ComputedValue
|
| ComputedValue
|
||||||
| typeof OptionalShortCircuit
|
| typeof OptionalShortCircuit
|
||||||
| undefined,
|
| undefined,
|
||||||
|
|
@ -1923,19 +2095,29 @@ export class Interpreter<R> {
|
||||||
return new PromiseMethodReference(key as PromiseMethodName)
|
return new PromiseMethodReference(key as PromiseMethodName)
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(
|
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,
|
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 (objectValue instanceof GlobalNamespace) {
|
||||||
if (typeof key === "string" && isBlockedMember(key)) {
|
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 (typeof key !== "string") return new ComputedValue(undefined)
|
||||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||||
}
|
}
|
||||||
|
if (objectValue.name === "JSON") {
|
||||||
|
if (jsonStatics.has(key)) return new JsonMethodReference(key as JsonMethodName)
|
||||||
|
return new ComputedValue(undefined)
|
||||||
|
}
|
||||||
if (globalStaticMembers[objectValue.name]?.has(key)) {
|
if (globalStaticMembers[objectValue.name]?.has(key)) {
|
||||||
return new GlobalMethodReference(objectValue.name, key)
|
return new GlobalMethodReference(objectValue.name, key)
|
||||||
}
|
}
|
||||||
|
|
@ -1945,7 +2127,7 @@ export class Interpreter<R> {
|
||||||
|
|
||||||
if (typeof objectValue === "string") {
|
if (typeof objectValue === "string") {
|
||||||
if (key === "length") return new ComputedValue(objectValue.length)
|
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 (index !== undefined) return new ComputedValue(objectValue[index])
|
||||||
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||||
return new ComputedValue(undefined)
|
return new ComputedValue(undefined)
|
||||||
|
|
@ -1958,7 +2140,7 @@ export class Interpreter<R> {
|
||||||
|
|
||||||
if (objectValue instanceof CoercionFunction) {
|
if (objectValue instanceof CoercionFunction) {
|
||||||
if (typeof key === "string" && isBlockedMember(key)) {
|
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 (typeof key !== "string") return new ComputedValue(undefined)
|
||||||
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
||||||
|
|
@ -2025,7 +2207,7 @@ export class Interpreter<R> {
|
||||||
|
|
||||||
if (isRuntimeReference(objectValue)) {
|
if (isRuntimeReference(objectValue)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(
|
||||||
"CodeMode runtime references are opaque and do not expose properties.",
|
"Runtime references are opaque and do not expose properties.",
|
||||||
objectNode,
|
objectNode,
|
||||||
"InvalidDataValue",
|
"InvalidDataValue",
|
||||||
)
|
)
|
||||||
|
|
@ -2036,12 +2218,12 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof key === "string" && isBlockedMember(key)) {
|
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 (Array.isArray(objectValue)) {
|
||||||
if (operation === "delete") return { target: objectValue, key }
|
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 (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
|
||||||
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
|
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
|
||||||
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
|
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
|
||||||
|
|
@ -2065,19 +2247,20 @@ export class Interpreter<R> {
|
||||||
reference instanceof PromiseMethodReference ||
|
reference instanceof PromiseMethodReference ||
|
||||||
reference instanceof PromiseInstanceMethodReference ||
|
reference instanceof PromiseInstanceMethodReference ||
|
||||||
reference instanceof IntrinsicReference ||
|
reference instanceof IntrinsicReference ||
|
||||||
reference instanceof GlobalMethodReference
|
reference instanceof GlobalMethodReference ||
|
||||||
|
reference instanceof JsonMethodReference
|
||||||
)
|
)
|
||||||
return reference
|
return reference
|
||||||
if (Array.isArray(reference.target)) {
|
if (Array.isArray(reference.target)) {
|
||||||
if (reference.key === "length") return reference.target.length
|
if (reference.key === "length") return reference.target.length
|
||||||
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
|
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 CodeModeRegExp) return reference.target.lastIndex
|
||||||
if (reference.target instanceof CodeModeURL) {
|
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2088,7 +2271,7 @@ export class Interpreter<R> {
|
||||||
private evaluateDeleteExpression(argument: AstNode): Effect.Effect<boolean, unknown, R> {
|
private evaluateDeleteExpression(argument: AstNode): Effect.Effect<boolean, unknown, R> {
|
||||||
const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument
|
const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument
|
||||||
if (target.type !== "MemberExpression") {
|
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) => {
|
return Effect.map(this.getMemberReference(target, "delete"), (reference) => {
|
||||||
if (reference === OptionalShortCircuit) return true
|
if (reference === OptionalShortCircuit) return true
|
||||||
|
|
@ -2100,9 +2283,10 @@ export class Interpreter<R> {
|
||||||
reference instanceof PromiseInstanceMethodReference ||
|
reference instanceof PromiseInstanceMethodReference ||
|
||||||
reference instanceof IntrinsicReference ||
|
reference instanceof IntrinsicReference ||
|
||||||
reference instanceof GlobalMethodReference ||
|
reference instanceof GlobalMethodReference ||
|
||||||
|
reference instanceof JsonMethodReference ||
|
||||||
reference.target instanceof CodeModeURL
|
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) {
|
if (reference.target instanceof CodeModeRegExp) {
|
||||||
return Reflect.deleteProperty(reference.target.regex, reference.key)
|
return Reflect.deleteProperty(reference.target.regex, reference.key)
|
||||||
|
|
@ -2127,33 +2311,33 @@ export class Interpreter<R> {
|
||||||
reference instanceof PromiseMethodReference ||
|
reference instanceof PromiseMethodReference ||
|
||||||
reference instanceof PromiseInstanceMethodReference ||
|
reference instanceof PromiseInstanceMethodReference ||
|
||||||
reference instanceof IntrinsicReference ||
|
reference instanceof IntrinsicReference ||
|
||||||
reference instanceof GlobalMethodReference
|
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 (Array.isArray(reference.target)) {
|
||||||
if (reference.key === "length")
|
if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned.", node)
|
||||||
throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node)
|
|
||||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
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))
|
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
|
||||||
if (write) self.assignToReference(reference, key, next, node)
|
if (write) self.assignToReference(reference, key, next, node)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private readReferenceValue(reference: MemberReference, key: number | string): unknown {
|
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
|
||||||
if (reference.target instanceof CodeModeURL) {
|
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
|
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)) {
|
if (Array.isArray(reference.target)) {
|
||||||
const target = reference.target
|
const target = reference.target
|
||||||
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
|
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
|
||||||
|
|
@ -2186,16 +2370,19 @@ export class Interpreter<R> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const target = reference.target as SafeObject
|
const target = reference.target as SafeObject
|
||||||
const objectKey = key as string
|
|
||||||
rejectCircularInsertion(target, next, "Object assignment result", node)
|
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") {
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
return value
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,23 @@ export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forE
|
||||||
|
|
||||||
export const mapStatics = new Set(["groupBy"])
|
export const mapStatics = new Set(["groupBy"])
|
||||||
|
|
||||||
export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
export const setMethods = new Set([
|
||||||
|
"add",
|
||||||
|
"has",
|
||||||
|
"delete",
|
||||||
|
"clear",
|
||||||
|
"forEach",
|
||||||
|
"keys",
|
||||||
|
"values",
|
||||||
|
"entries",
|
||||||
|
"union",
|
||||||
|
"intersection",
|
||||||
|
"difference",
|
||||||
|
"symmetricDifference",
|
||||||
|
"isSubsetOf",
|
||||||
|
"isSupersetOf",
|
||||||
|
"isDisjointFrom",
|
||||||
|
])
|
||||||
|
|
||||||
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
|
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
|
||||||
if (Array.isArray(value)) return value
|
if (Array.isArray(value)) return value
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||||
seen.delete(value)
|
seen.delete(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isRuntimeReference(value)) return "[CodeMode reference]"
|
if (isRuntimeReference(value)) return "[opaque reference]"
|
||||||
seen.add(value)
|
seen.add(value)
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(value)) {
|
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 => {
|
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
|
||||||
if (value === undefined) return "undefined"
|
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 data = boundedData(value, "console.table argument")
|
||||||
const columns = consoleTableColumns(columnsArgument)
|
const columns = consoleTableColumns(columnsArgument)
|
||||||
const rows = consoleTableRows(data, columns)
|
const rows = consoleTableRows(data, columns)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
|
||||||
case "UTC":
|
case "UTC":
|
||||||
return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))
|
return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))
|
||||||
default:
|
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]))
|
if (args.length < 3) return updateDate(value, hosted.setUTCFullYear(args[0], args[1]))
|
||||||
return updateDate(value, hosted.setUTCFullYear(args[0], args[1], args[2]))
|
return updateDate(value, hosted.setUTCFullYear(args[0], args[1], args[2]))
|
||||||
default:
|
default:
|
||||||
throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`Date method '${name}' is not available.`, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,149 @@
|
||||||
import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from "../interpreter/model.js"
|
import { Effect } from "effect"
|
||||||
|
import type { CallbackRunner } from "../interpreter/methods.js"
|
||||||
|
import { applyCollectionCallback } from "../interpreter/methods.js"
|
||||||
|
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||||
import { typeofValue } from "../interpreter/references.js"
|
import { typeofValue } from "../interpreter/references.js"
|
||||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
import { copyIn, copyOut, type SafeObject } from "../tool-runtime.js"
|
||||||
|
import {
|
||||||
|
CodeModeDate,
|
||||||
|
CodeModeMap,
|
||||||
|
CodeModeRegExp,
|
||||||
|
CodeModeSet,
|
||||||
|
CodeModeURL,
|
||||||
|
CodeModeURLSearchParams,
|
||||||
|
} from "../values.js"
|
||||||
|
|
||||||
export const jsonStatics = new Set(["parse", "stringify"])
|
export const jsonStatics = new Set(["parse", "stringify"])
|
||||||
|
export type JsonMethodName = "parse" | "stringify"
|
||||||
|
|
||||||
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
export const invokeJsonMethod = <R>(
|
||||||
switch (name) {
|
runner: CallbackRunner<R>,
|
||||||
case "stringify": {
|
name: JsonMethodName,
|
||||||
const replacer = args[1]
|
args: Array<unknown>,
|
||||||
if (Array.isArray(replacer) || typeofValue(replacer) === "function") {
|
node: AstNode,
|
||||||
throw new InterpreterRuntimeError(
|
): Effect.Effect<unknown, unknown, R> => {
|
||||||
"JSON.stringify replacers are not supported in CodeMode.",
|
return name === "parse" ? parse(runner, args, node) : stringify(runner, args, node)
|
||||||
node,
|
|
||||||
"UnsupportedSyntax",
|
|
||||||
[supportedSyntaxMessage],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const space = args[2]
|
|
||||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
|
||||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value"), "json"), null, indent)
|
|
||||||
}
|
|
||||||
case "parse": {
|
|
||||||
const text = args[0]
|
|
||||||
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
|
|
||||||
if (typeofValue(args[1]) === "function") {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
"JSON.parse revivers are not supported in CodeMode.",
|
|
||||||
node,
|
|
||||||
"UnsupportedSyntax",
|
|
||||||
[supportedSyntaxMessage],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return copyIn(JSON.parse(text), "JSON.parse result")
|
|
||||||
} catch (error) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
node,
|
|
||||||
).as("SyntaxError")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parse = <R>(
|
||||||
|
runner: CallbackRunner<R>,
|
||||||
|
args: Array<unknown>,
|
||||||
|
node: AstNode,
|
||||||
|
): Effect.Effect<unknown, unknown, R> => {
|
||||||
|
const text = args[0]
|
||||||
|
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
|
||||||
|
|
||||||
|
const parsed = (() => {
|
||||||
|
try {
|
||||||
|
return copyIn(JSON.parse(text), "JSON.parse result")
|
||||||
|
} catch (error) {
|
||||||
|
throw new InterpreterRuntimeError(
|
||||||
|
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
node,
|
||||||
|
).as("SyntaxError")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed)
|
||||||
|
|
||||||
|
const apply = applyCollectionCallback(runner, args[1], "JSON.parse", node)
|
||||||
|
const root: SafeObject = Object.create(null) as SafeObject
|
||||||
|
root[""] = parsed
|
||||||
|
const visit = (holder: SafeObject | Array<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const value = holder[key as keyof typeof holder]
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const length = value.length
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const revived = yield* visit(value, String(index))
|
||||||
|
if (revived === undefined) Reflect.deleteProperty(value, index)
|
||||||
|
else value[index] = revived
|
||||||
|
}
|
||||||
|
} else if (isPlainObject(value)) {
|
||||||
|
for (const name of Object.keys(value)) {
|
||||||
|
const revived = yield* visit(value, name)
|
||||||
|
if (revived === undefined) Reflect.deleteProperty(value, name)
|
||||||
|
else value[name] = revived
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return yield* apply([key, value])
|
||||||
|
})
|
||||||
|
return visit(root, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringify = <R>(
|
||||||
|
runner: CallbackRunner<R>,
|
||||||
|
args: Array<unknown>,
|
||||||
|
node: AstNode,
|
||||||
|
): Effect.Effect<unknown, unknown, R> => {
|
||||||
|
const space = args[2]
|
||||||
|
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||||
|
const replacer = args[1]
|
||||||
|
const callable = typeofValue(replacer) === "function"
|
||||||
|
const checked = copyIn(args[0], "JSON.stringify value", callable)
|
||||||
|
const input = callable ? args[0] : checked
|
||||||
|
|
||||||
|
if (Array.isArray(replacer)) {
|
||||||
|
const properties = replacer
|
||||||
|
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
|
||||||
|
.map(String)
|
||||||
|
return Effect.succeed(JSON.stringify(copyOut(input, "json"), properties, indent))
|
||||||
|
}
|
||||||
|
if (!callable) {
|
||||||
|
return Effect.succeed(JSON.stringify(copyOut(input, "json"), null, indent))
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node)
|
||||||
|
const root: SafeObject = Object.create(null) as SafeObject
|
||||||
|
root[""] = input
|
||||||
|
const stack = new Set<object>()
|
||||||
|
const visit = (holder: SafeObject | Array<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const value = yield* apply([key, toJSONValue(holder[key as keyof typeof holder])])
|
||||||
|
if (value === undefined || typeofValue(value) === "function") return undefined
|
||||||
|
copyIn(value, "JSON.stringify replacer result", true)
|
||||||
|
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||||
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (stack.has(value))
|
||||||
|
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
|
||||||
|
stack.add(value)
|
||||||
|
const result: Array<unknown> = []
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
result.push((yield* visit(value, String(index))) ?? null)
|
||||||
|
}
|
||||||
|
stack.delete(value)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (!isPlainObject(value)) return {}
|
||||||
|
if (stack.has(value))
|
||||||
|
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
|
||||||
|
stack.add(value)
|
||||||
|
const result: SafeObject = Object.create(null) as SafeObject
|
||||||
|
for (const name of Object.keys(value)) {
|
||||||
|
const item = yield* visit(value, name)
|
||||||
|
if (item !== undefined) result[name] = item
|
||||||
|
}
|
||||||
|
stack.delete(value)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return Effect.map(visit(root, ""), (value) => JSON.stringify(value, null, indent))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toJSONValue = (value: unknown): unknown => {
|
||||||
|
if (value instanceof CodeModeDate) {
|
||||||
|
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||||
|
}
|
||||||
|
if (value instanceof CodeModeURL) return value.url.href
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is SafeObject =>
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!(value instanceof CodeModeDate) &&
|
||||||
|
!(value instanceof CodeModeRegExp) &&
|
||||||
|
!(value instanceof CodeModeMap) &&
|
||||||
|
!(value instanceof CodeModeSet) &&
|
||||||
|
!(value instanceof CodeModeURL) &&
|
||||||
|
!(value instanceof CodeModeURLSearchParams)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export const mathMethods = new Set([
|
||||||
])
|
])
|
||||||
|
|
||||||
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
|
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 === "random") return Math.random()
|
||||||
if (name === "sumPrecise") {
|
if (name === "sumPrecise") {
|
||||||
const items = spreadItems(args[0])
|
const items = spreadItems(args[0])
|
||||||
|
|
@ -151,5 +151,5 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
|
||||||
case "imul":
|
case "imul":
|
||||||
return Math.imul(a, b())
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export const invokeNumberMethod = (value: number, name: string, args: Array<unkn
|
||||||
result = value
|
result = value
|
||||||
break
|
break
|
||||||
default:
|
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`)
|
return boundedData(result, `Number.${name} result`)
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +71,7 @@ export const invokeNumberStatic = (name: string, args: Array<unknown>, node: Ast
|
||||||
case "parseFloat":
|
case "parseFloat":
|
||||||
return parseFloat(coerceToString(value))
|
return parseFloat(coerceToString(value))
|
||||||
default:
|
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"
|
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||||
|
|
|
||||||
|
|
@ -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 { containsOpaqueReference } from "../interpreter/references.js"
|
||||||
import { isBlockedMember } from "../tool-runtime.js"
|
import { isBlockedMember } from "../tool-runtime.js"
|
||||||
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.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>
|
return input as Record<string, unknown>
|
||||||
}
|
}
|
||||||
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
|
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
|
out[key] = item
|
||||||
}
|
}
|
||||||
const addEntry = (out: Record<string, unknown>, key: unknown, item: unknown): void => {
|
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":
|
case "entries":
|
||||||
return Object.entries(requireObject()).map(([key, item]) => [key, item])
|
return Object.entries(requireObject()).map(([key, item]) => [key, item])
|
||||||
case "hasOwn":
|
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":
|
case "is":
|
||||||
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
|
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])
|
return Object.is(args[0], args[1])
|
||||||
case "assign": {
|
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)
|
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||||
}
|
}
|
||||||
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
@ -94,5 +106,5 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`Object.${name} is not available.`, node)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invokeRegExpStatic = (name: string, args: Array<unknown>, node: AstNode): string => {
|
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") {
|
if (typeof args[0] !== "string") {
|
||||||
throw new InterpreterRuntimeError("RegExp.escape expects a string.", node).as("TypeError")
|
throw new InterpreterRuntimeError("RegExp.escape expects a string.", node).as("TypeError")
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +104,7 @@ export const invokeRegExpMethod = (
|
||||||
case "toString":
|
case "toString":
|
||||||
return coerceToString(value)
|
return coerceToString(value)
|
||||||
default:
|
default:
|
||||||
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
|
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available.`, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export const invokeStringStatic = (name: string, args: Array<unknown>, node: Ast
|
||||||
case "fromCodePoint":
|
case "fromCodePoint":
|
||||||
return String.fromCodePoint(...codes)
|
return String.fromCodePoint(...codes)
|
||||||
default:
|
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"
|
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export const urlArgument = (value: unknown, label: string): string =>
|
||||||
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
|
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
|
||||||
|
|
||||||
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
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")
|
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 input = urlArgument(args[0], `URL.${name} input`)
|
||||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
|
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 => {
|
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
|
||||||
if (name === "toString" || name === "toJSON") return value.url.href
|
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 { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
|
||||||
import { CodeModeURL } from "../values.js"
|
import { CodeModeURL } from "../values.js"
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue