Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton d6f36bb750 refactor(core): remove output paths metadata 2026-07-19 12:19:46 +00:00
264 changed files with 7571 additions and 14425 deletions
-1
View File
@@ -902,7 +902,6 @@
"@napi-rs/canvas": "1.0.2",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"effect": "catalog:",
},
+2 -43
View File
@@ -1,6 +1,6 @@
# @opencode-ai/ai
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
```ts
import { Effect } from "effect"
@@ -24,45 +24,6 @@ const program = Effect.gen(function* () {
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Image generation
Use `Image.generate` with an image model for direct asset generation:
```ts
import { Image } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
const program = Effect.gen(function* () {
const response = yield* Image.generate({
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: 2,
size: { width: 1024, height: 1024 },
providerOptions: { openai: { quality: "high", outputFormat: "webp" } },
})
return response.images // GeneratedImage[] with owned bytes or a provider URL
})
```
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
```ts
const program = Effect.gen(function* () {
const response = yield* LLM.generate(
LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
prompt: "Design a solarpunk rooftop garden, then show me.",
tools: [OpenAI.imageGeneration({ quality: "high" })],
}),
)
return response.message
})
```
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
@@ -71,8 +32,6 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
## Caching
@@ -223,7 +182,7 @@ Adding a new model or deployment is usually 5-15 lines using `Route.make({ proto
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
-34
View File
@@ -1,34 +0,0 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor"
import type { ImageRequest, ImageResponse } from "./image"
import type { LLMError } from "./schema"
export type Execute = RequestExecutor.Interface["execute"]
export interface Interface {
readonly generate: (request: ImageRequest) => Effect.Effect<ImageResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = (request: ImageRequest): Effect.Effect<ImageResponse, LLMError> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
}) as Effect.Effect<ImageResponse, LLMError>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) => request.model.route.generate(request, executor.execute),
})
}),
)
export const ImageClient = {
Service,
layer,
generate,
} as const
-116
View File
@@ -1,116 +0,0 @@
import { Effect, Schema } from "effect"
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { ImageClient, type Execute as ImageExecute } from "./image-client"
export interface ImageRoute {
readonly id: string
readonly generate: (request: ImageRequest, execute: ImageExecute) => Effect.Effect<ImageResponse, LLMError>
}
export class ImageModel {
readonly id: ModelID
readonly provider: ProviderID
readonly route: ImageRoute
readonly defaults?: ImageModelDefaults
constructor(input: ImageModel.Input) {
this.id = input.id
this.provider = input.provider
this.route = input.route
this.defaults = input.defaults
}
static make(input: ImageModel.MakeInput) {
return new ImageModel({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
defaults: input.defaults,
})
}
}
export namespace ImageModel {
export interface Input {
readonly id: ModelID
readonly provider: ProviderID
readonly route: ImageRoute
readonly defaults?: ImageModelDefaults
}
export interface MakeInput extends Omit<Input, "id" | "provider"> {
readonly id: string | ModelID
readonly provider: string | ProviderID
}
}
export interface ImageModelDefaults {
readonly providerOptions?: Record<string, Record<string, unknown>>
readonly http?: HttpOptions
}
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
expected: "Image.Model",
})
export const ImageSize = Schema.Struct({
width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
}).annotate({ identifier: "Image.Size" })
export type ImageSize = Schema.Schema.Type<typeof ImageSize>
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
model: ImageModelSchema,
prompt: Schema.String,
count: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
size: Schema.optional(ImageSize),
aspectRatio: Schema.optional(Schema.String),
seed: Schema.optional(Schema.Number),
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
http: Schema.optional(HttpOptions),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
export type ImageRequestInput = Omit<ConstructorParameters<typeof ImageRequest>[0], "http"> & {
readonly http?: HttpOptions.Input
}
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
mediaType: Schema.String,
data: Schema.Union([Schema.String, Schema.Uint8Array]),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
images: Schema.Array(GeneratedImage),
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
}) {
get image() {
return this.images[0]
}
}
export const request = (input: ImageRequest | ImageRequestInput) => {
if (input instanceof ImageRequest) return input
return new ImageRequest({
...input,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
}
export const generate = (input: ImageRequest | ImageRequestInput) =>
Effect.try({
try: () => request(input),
catch: (error) =>
new LLMError({
module: "Image",
method: "generate",
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
}),
}).pipe(Effect.flatMap(ImageClient.generate))
export const Image = {
request,
generate,
} as const
-4
View File
@@ -1,5 +1,4 @@
export { LLMClient } from "./route/client"
export { ImageClient } from "./image-client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
@@ -11,9 +10,6 @@ export type {
Service as LLMClientService,
} from "./route/client"
export * from "./schema"
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
export { Image } from "./image"
export { Tool, ToolFailure, toDefinitions } from "./tool"
export { ToolRuntime } from "./tool-runtime"
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
-1
View File
@@ -2,7 +2,6 @@ export * as AnthropicMessages from "./anthropic-messages"
export * as BedrockConverse from "./bedrock-converse"
export * as Gemini from "./gemini"
export * as OpenAIChat from "./openai-chat"
export * as OpenAIImages from "./openai-images"
export * as OpenAICompatibleChat from "./openai-compatible-chat"
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
export * as OpenAIResponses from "./openai-responses"
+18 -147
View File
@@ -77,7 +77,6 @@ const OpenAIChatMessage = Schema.Union([
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: optionalArray(Schema.Unknown),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
@@ -150,7 +149,6 @@ const OpenAIChatDelta = Schema.Struct({
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
@@ -166,23 +164,13 @@ export const OpenAIChatEvent = Schema.Struct({
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface PendingToolDelta {
readonly id?: string
readonly name?: string
readonly input: string
}
export interface ParserState {
readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
}
// =============================================================================
@@ -228,15 +216,7 @@ const openAICompatibleReasoningContent = (native: unknown) =>
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
const observed = parts.flatMap((part) => {
const details = part.providerMetadata?.openai?.reasoningDetails
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
return "reasoning_content"
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
@@ -280,28 +260,19 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
}
}
const text = reasoning.map((part) => part.text).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (reasoning.length === 0) return
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningContent = (() => {
if (reasoning.length === 0) return nativeReasoning
if (field === "reasoning_content") return text
})()
const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
return {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content: reasoningContent,
reasoning_content:
reasoning.length === 0
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
: field === "reasoning_content"
? text
: undefined,
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
reasoning_details: details,
}
})
@@ -452,59 +423,6 @@ const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
}
const detailText = (details: ReadonlyArray<unknown>) => {
const text = details.flatMap((detail) => {
if (!isRecord(detail)) return []
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
return [detail.summary]
return []
})
if (text.length > 0) return text.join("")
}
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
for (const detail of details) {
const previous = result.at(-1)
if (
!isRecord(previous) ||
previous.type !== "reasoning.text" ||
!isRecord(detail) ||
detail.type !== "reasoning.text" ||
conflictingReasoningTextDetails(previous, detail)
) {
result.push(detail)
continue
}
result[result.length - 1] = {
...previous,
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
signature: mergeDetailValue(previous.signature, detail.signature),
format: mergeDetailValue(previous.format, detail.format),
}
}
}
const mergeDetailValue = (previous: unknown, current: unknown) =>
previous || current || (previous !== undefined ? previous : current)
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
conflictingDetailValue(previous.id, current.id) ||
conflictingDetailValue(previous.index, current.index) ||
conflictingDetailValue(previous.format, current.format) ||
(Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
const conflictingDetailValue = (previous: unknown, current: unknown) =>
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
openai: {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
})
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
const events: LLMEvent[] = []
@@ -514,56 +432,29 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
let pendingTools = state.pendingTools
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta)
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (!state.lifecycle.text.has("text-0") && text !== undefined)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
const reasoningField = state.reasoningField ?? reasoning?.field
if (reasoning)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
openai: { reasoningField: reasoningField ?? reasoning.field },
})
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
for (const tool of toolDeltas) {
const current = tools[tool.index]
const pending = pendingTools[tool.index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
if (!current && (!id || !name)) {
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
continue
}
if (pending) {
pendingTools = { ...pendingTools }
delete pendingTools[tool.index]
}
const result = ToolStream.appendOrStart(
ADAPTER,
tools,
tool.index,
{ id: id || undefined, name: name || undefined, text },
{ id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result)) return yield* result
@@ -572,9 +463,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
events.push(...result.events)
}
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// valid calls and malformed local calls settle independently.
const finished =
@@ -585,15 +473,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
usage,
finishReason,
lifecycle,
reasoningField,
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
},
events,
] as const
@@ -603,16 +487,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...state.toolCallEvents)
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
@@ -637,13 +512,9 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: undefined,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
}),
step,
onHalt: finishEvents,
-208
View File
@@ -1,208 +0,0 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
ImageModel,
GeneratedImage,
ImageResponse,
type ImageRequest,
type ImageModelDefaults,
type ImageRoute,
} from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJsonRecords } from "../schema"
import { ProviderShared } from "./shared"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-images"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/images/generations"
export interface OpenAIImageOptions {
readonly quality?: "auto" | "low" | "medium" | "high"
readonly background?: "auto" | "opaque" | "transparent"
readonly moderation?: "auto" | "low"
readonly outputFormat?: "png" | "jpeg" | "webp"
readonly outputCompression?: number
}
const OpenAIImageBody = Schema.Struct({
model: Schema.String,
prompt: Schema.String,
n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
size: Schema.optional(Schema.String),
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
moderation: Schema.optional(Schema.Literals(["auto", "low"])),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
})
export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
const OpenAIImageResponse = Schema.Struct({
data: Schema.Array(
Schema.Struct({
b64_json: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
revised_prompt: Schema.optional(Schema.String),
}),
),
output_format: Schema.optional(Schema.String),
usage: Schema.optional(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
),
})
export interface ModelInput {
readonly id: string
readonly auth: AuthDefinition
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly defaults?: ImageModelDefaults
}
const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({
...request.model.defaults?.providerOptions?.openai,
...request.providerOptions?.openai,
})
const body = (request: ImageRequest): OpenAIImageBody => {
const options = providerOptions(request)
return {
model: request.model.id,
prompt: request.prompt,
n: request.count,
size: request.size === undefined ? undefined : `${request.size.width}x${request.size.height}`,
quality: options.quality,
background: options.background,
moderation: options.moderation,
output_format: options.outputFormat,
output_compression: options.outputCompression,
}
}
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()
}
const PROTOCOL_BODY_FIELDS = new Set([
"model",
"prompt",
"n",
"size",
"quality",
"background",
"moderation",
"output_format",
"output_compression",
])
const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
imageBody: OpenAIImageBody,
overlay: Record<string, unknown> | undefined,
) {
if (!overlay) return imageBody
const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key))
if (reserved.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`,
)
return mergeJsonRecords(imageBody, overlay) ?? imageBody
})
export const model = (input: ModelInput) => {
const route: ImageRoute = {
id: ADAPTER,
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) {
if (request.aspectRatio !== undefined)
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option")
if (request.seed !== undefined)
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option")
const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request))
const http = mergeHttpOptions(request.model.defaults?.http, request.http)
const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
const text = ProviderShared.encodeJson(overlaidBody)
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 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 ?? providerOptions(request).outputFormat ?? "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({ id: input.id, provider: "openai", route, defaults: input.defaults })
}
export const OpenAIImages = {
model,
} as const
+21 -79
View File
@@ -1,4 +1,4 @@
import { Effect, Encoding, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -25,7 +25,6 @@ import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-responses"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
@@ -114,24 +113,11 @@ const OpenAIResponsesTool = Schema.Struct({
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
})
const OpenAIResponsesImageGenerationTool = Schema.Struct({
type: Schema.tag("image_generation"),
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
input_fidelity: Schema.optional(Schema.Literals(["low", "high"])),
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
partial_images: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesTools = Schema.Union([OpenAIResponsesTool, OpenAIResponsesImageGenerationTool])
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTools>
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
const OpenAIResponsesToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({ type: Schema.tag("image_generation") }),
])
// Fields shared between the HTTP body and the WebSocket `response.create`
@@ -142,7 +128,7 @@ const OpenAIResponsesCoreFields = {
model: Schema.String,
input: Schema.Array(OpenAIResponsesInputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(OpenAIResponsesTools),
tools: optionalArray(OpenAIResponsesTool),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
@@ -208,8 +194,6 @@ const OpenAIResponsesStreamItem = Schema.Struct({
outputs: Schema.optional(Schema.Unknown),
server_label: Schema.optional(Schema.String),
output: Schema.optional(Schema.Unknown),
result: Schema.optional(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
error: Schema.optional(Schema.Unknown),
encrypted_content: optionalNull(Schema.String),
})
@@ -274,41 +258,21 @@ const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
}
const nativeImageTool = (tool: ToolDefinition) => {
const native = nativeImageToolInput(tool)
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
}
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
const native = nativeImageToolInput(tool)
if (native !== undefined) {
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
return yield* invalid("OpenAI Responses image generation tool options are invalid")
}
return {
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
}
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "required" as const,
tool: (name) =>
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
? ({ type: "image_generation" } as const)
: { type: "function" as const, name },
tool: (name) => ({ type: "function" as const, name }),
})
const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
@@ -456,13 +420,6 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const itemID = hostedToolItemID(part)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.name === "image_generation" && part.result.type === "content") {
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, lowerToolResultContentItem),
})
}
if (itemID) hostedToolReferences.add(itemID)
continue
}
@@ -528,10 +485,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
: request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -617,29 +574,14 @@ const isReasoningItem = (
// Round-trip the full item as the structured result so consumers can extract
// outputs / sources / status without re-decoding.
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: OpenAIResponsesStreamItem) {
const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
const isError = typeof item.error !== "undefined" && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
)
return {
type: "content" as const,
value: [
{
type: "file" as const,
uri: `data:image/${item.output_format ?? "png"};base64,${item.result}`,
mime: `image/${item.output_format ?? "png"}`,
},
],
}
}
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
})
}
const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* (
const hostedToolEvents = (
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
) {
): ReadonlyArray<LLMEvent> => {
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = openaiMetadata({ itemId: item.id })
return [
@@ -653,12 +595,12 @@ const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function*
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: yield* hostedToolResult(item),
result: hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
]
})
}
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -905,7 +847,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
if (isHostedToolItem(item)) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(...(yield* hostedToolEvents(item)))
events.push(...hostedToolEvents(item))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const reasoningDelta = (
providerMetadata?: ProviderMetadata,
): State => {
const started = reasoningStart(state, events, id, providerMetadata)
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
events.push(LLMEvent.reasoningDelta({ id, text }))
return started
}
@@ -1,20 +0,0 @@
import { Schema } from "effect"
const dimensions = (value: string) => {
const match = /^(\d+)x(\d+)$/.exec(value)
if (!match) return undefined
return { width: Number(match[1]), height: Number(match[2]) }
}
export const Size = Schema.String.check(
Schema.makeFilter((value) => {
if (value === "auto") return undefined
const parsed = dimensions(value)
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
}),
)
export const OpenAIImage = {
Size,
} as const
@@ -140,8 +140,8 @@ export const appendOrStart = <K extends StreamKey>(
missingToolMessage: string,
): AppendOutcome<K> | LLMError => {
const current = tools[key]
const id = current?.id ?? delta.id
const name = current?.name ?? delta.name
const id = delta.id ?? current?.id
const name = delta.name ?? current?.name
if (!id || !name) return eventError(route, missingToolMessage)
const tool = {
+1 -11
View File
@@ -16,17 +16,13 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
/input token count.*exceeds the maximum/i,
/tokens in request more than max tokens allowed/i,
/maximum prompt length is \d+/i,
/reduce the length of the messages/i,
/maximum context length is \d+ tokens/i,
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
/exceeds the limit of \d+/i,
/exceeds the available context size/i,
/greater than the context length/i,
@@ -38,17 +34,11 @@ const patterns = [
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
/too many tokens/i,
/token limit exceeded/i,
]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
+2 -57
View File
@@ -1,14 +1,12 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { Route, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
export type { OpenAIImageOptions } from "../protocols/openai-images"
export const id = ProviderID.make("openai")
@@ -22,44 +20,8 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
readonly image?: ImageConfig
}
export interface ImageConfig {
readonly providerOptions?: OpenAIImageOptions
}
export interface ImageGenerationOptions {
readonly action?: "auto" | "generate" | "edit"
readonly background?: "auto" | "opaque" | "transparent"
readonly inputFidelity?: "low" | "high"
readonly outputCompression?: number
readonly outputFormat?: "png" | "jpeg" | "webp"
readonly partialImages?: number
readonly quality?: "auto" | "low" | "medium" | "high"
readonly size?: string
}
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
ToolDefinition.make({
name: "image_generation",
description: "Generate or edit an image using OpenAI's hosted image generation tool.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
native: {
openai: {
type: "image_generation",
action: options.action,
background: options.background,
input_fidelity: options.inputFidelity,
output_compression: options.outputCompression,
output_format: options.outputFormat,
partial_images: options.partialImages,
quality: options.quality,
size: options.size,
},
},
})
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
@@ -73,7 +35,7 @@ export interface Settings extends ProviderPackage.Settings {
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
const defaults = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, image: _image, ...rest } = input
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
return rest
}
@@ -93,21 +55,6 @@ export const configure = (input: Config = {}) => {
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
defaults: {
providerOptions:
input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
http: mergeHttpOptions(
input.http === undefined ? undefined : HttpOptions.make(input.http),
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
),
},
})
return {
id,
@@ -115,7 +62,6 @@ export const configure = (input: Config = {}) => {
responses,
responsesWebSocket,
chat,
image,
configure,
}
}
@@ -151,4 +97,3 @@ export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
export const image = provider.image
+7 -25
View File
@@ -41,31 +41,13 @@ export const protocol = Protocol.make({
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
const messages = body.messages.map((message) => {
if (message.role !== "assistant") return message
const source = sourceAssistants[assistantIndex++]
const reasoning = source?.content
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
.join("")
const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
return {
...message,
reasoning_content: undefined,
reasoning_text: undefined,
reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
reasoning_details: reasoningDetails,
}
})
return {
...body,
messages,
...bodyOptions(request.providerOptions?.openrouter),
} as OpenRouterBody
}),
Effect.map(
(body) =>
({
...body,
...bodyOptions(request.providerOptions?.openrouter),
}) as OpenRouterBody,
),
),
},
stream: OpenAIChat.protocol.stream,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema"
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -15,7 +15,7 @@ export type AuthError = CredentialError | LLMError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput {
readonly request: { readonly http?: HttpOptions }
readonly request: LLMRequest
readonly method: "POST" | "GET"
readonly url: string
readonly body: string
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-95
View File
@@ -1,95 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient } from "../src"
import { OpenAI } from "../src/providers"
import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
describe("Image", () => {
it.effect("generates images through the OpenAI Images API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: OpenAI.configure({
apiKey: "test",
baseURL: "https://api.openai.test/v1",
queryParams: { "api-version": "v1" },
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
}).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: 2,
size: { width: 1024, height: 1024 },
providerOptions: {
openai: { quality: "high", outputFormat: "webp" },
},
http: {
body: { request_metadata: "value" },
headers: { "x-request": "yes" },
query: { trace: "1" },
},
})
expect(response.images).toHaveLength(2)
expect(response.image?.mediaType).toBe("image/webp")
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
expect(response.image?.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
expect(response.usage?.totalTokens).toBe(12)
}).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/generations?api-version=v1&trace=1")
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: "gpt-image-2",
prompt: "A robot tending a rooftop garden",
n: 2,
size: "1024x1024",
quality: "high",
output_format: "webp",
deployment: "test",
request_metadata: "value",
})
return input.respond(
JSON.stringify({
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
output_format: "webp",
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("rejects invalid common and OpenAI image options locally", () =>
Image.generate({
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: -1,
size: { width: -1, height: 0.5 },
providerOptions: { openai: { outputCompression: 101 } },
}).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.sync(() => {
expect(error.reason._tag).toBe("InvalidRequest")
}),
),
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))),
),
),
),
)
})
+2 -24
View File
@@ -3,30 +3,8 @@ import { isContextOverflow } from "../src"
import { classifyProviderFailure } from "../src/provider-error"
describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Too many tokens",
"Token limit exceeded",
]
expect(messages.every(isContextOverflow)).toBe(true)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
"Rate limit exceeded, please retry after 30 seconds.",
"Too many requests. Please slow down.",
]
expect(messages.some(isContextOverflow)).toBe(false)
test("classifies Z.AI GLM token limit messages as context overflow", () => {
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
})
test("classifies V1 plain-text rate limit fallbacks", () => {
+1 -54
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src"
import { LLM } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
@@ -83,59 +83,6 @@ describe("Cloudflare", () => {
}),
)
it.effect("preserves reasoning details for AI Gateway continuation", () =>
Effect.gen(function* () {
const model = CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "test-gateway",
apiKey: "test-token",
}).model("anthropic/claude-sonnet-4.6")
const details = [
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
]
const merged = [
{
type: "reasoning.text",
text: "Thinking",
signature: "signed",
format: "anthropic-claude-v1",
index: 0,
},
]
const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(
sseEvents(
deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
deltaChunk({ reasoning_details: [details[2]] }),
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
),
{ headers: { "content-type": "text/event-stream" } },
),
),
),
),
)
expect(response.reasoning).toBe("Thinking")
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
])
}),
)
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
@@ -1,12 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat"
import { LLM, LLMEvent } from "../../src"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route"
import { recordedTests } from "../recorded-test"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
const cases = [
{
@@ -17,7 +15,6 @@ const cases = [
}).model("anthropic/claude-sonnet-4.6"),
requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning",
structured: true,
},
{
name: "Vercel AI Gateway",
@@ -29,7 +26,6 @@ const cases = [
}).model("anthropic/claude-sonnet-4.6"),
requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning",
structured: true,
},
] as const
@@ -61,82 +57,11 @@ for (const item of cases) {
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
if (!item.structured) return
const details = metadata?.openai?.reasoningDetails
if (!Array.isArray(details)) return
expect(
details.some(
(detail) =>
typeof detail === "object" &&
detail !== null &&
"signature" in detail &&
typeof detail.signature === "string" &&
detail.signature.length > 0,
),
).toBe(true)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: item.model, messages: [response.message] }),
)
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
])
const replayDetails =
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
expect(Array.isArray(replayDetails)).toBe(true)
if (!Array.isArray(replayDetails)) return
expect(replayDetails).toEqual(details)
expect(replayDetails).toHaveLength(1)
expect(replayDetails[0]).toMatchObject({
type: "reasoning.text",
text: response.reasoning,
signature: expect.any(String),
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning" },
})
}),
30_000,
)
recorded.effect.with(
"continues signed reasoning through a tool loop",
{ cassette: `${item.cassette}-tool-loop`, tags: ["continuation", "tool", "tool-loop"] },
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
id: `${item.cassette}-tool-loop`,
model: item.model,
maxTokens: 1536,
temperature: false,
}),
)
expectWeatherToolLoop(events)
expect(
LLMResponse.text({
events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1),
}).trim(),
).toMatch(/^Paris is sunny\.?$/)
const details = events
.filter(LLMEvent.is.reasoningEnd)
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
.find(Array.isArray)
expect(Array.isArray(details)).toBe(item.structured)
if (!item.structured || !Array.isArray(details)) return
expect(
details.some(
(detail) =>
typeof detail === "object" &&
detail !== null &&
"signature" in detail &&
typeof detail.signature === "string" &&
detail.signature.length > 0,
),
).toBe(true)
}),
60_000,
)
})
}
@@ -570,375 +570,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
]
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
],
},
finish_reason: "tool_calls",
},
],
},
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning: "thinking",
reasoning_details: details,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: '{"query":"weather"}' },
},
],
},
])
}),
)
it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
}),
)
it.effect("preserves unknown reasoning details while using scalar display text", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
}),
)
it.effect("uses scalar display text for signature-only reasoning details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
}),
)
it.effect("ignores scalar reasoning after content starts", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: { reasoning: "scalar" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("detail")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: details },
})
}),
)
it.effect("preserves an explicitly empty reasoning details array", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: [] },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
}),
)
it.effect("attaches signature-only details that arrive after content", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
]
const merged = [
{
type: "reasoning.text",
text: "thinking",
signature: "signed",
format: "anthropic-claude-v1",
index: 0,
},
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
])
}),
)
it.effect("preserves metadata-only reasoning when the stream ends", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
])
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}),
)
it.effect("flushes details-only display reasoning when the stream ends", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("summary")
expect(response.message.content).toEqual([
{ type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
])
}),
)
it.effect("replays details from multiple reasoning parts in order", () =>
Effect.gen(function* () {
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "first",
providerMetadata: { openai: { reasoningDetails: [first] } },
},
{
type: "reasoning",
text: "second",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
},
]),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
])
}),
)
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
Effect.gen(function* () {
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "A",
providerMetadata: { openai: { reasoningDetails: [detail] } },
},
{ type: "reasoning", text: "B" },
]),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
])
}),
)
it.effect("replays native scalar reasoning alongside native details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.make({
role: "assistant",
content: [{ type: "reasoning", text: "thinking" }],
native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
}),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
])
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -975,67 +606,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("ignores empty identity fields on later tool call deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
}),
deltaChunk({
tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
}),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
it.effect("buffers tool call deltas until the function name arrives", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
}),
deltaChunk({
tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
}),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
it.effect("fails when a buffered tool call never receives a function name", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
}),
deltaChunk({}, "tool_calls"),
)
const error = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
}),
)
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -1,40 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src"
import { OpenAI } from "../../src/providers"
import { recordedTests } from "../recorded-test"
const model = OpenAI.configure({
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
image: {
providerOptions: {
quality: "low",
outputFormat: "jpeg",
outputCompression: 10,
},
},
}).image("gpt-image-1-mini")
const recorded = recordedTests({
prefix: "openai-images",
provider: "openai",
protocol: "openai-images",
requires: ["OPENAI_API_KEY"],
})
describe("OpenAI Images recorded", () => {
recorded.effect("generates an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "A simple flat black circle centered on a plain white background.",
size: { width: 1024, height: 1024 },
})
expect(response.images).toHaveLength(1)
expect(response.image?.mediaType).toBe("image/jpeg")
expect(response.image?.data).toBeInstanceOf(Uint8Array)
expect(response.image?.data.length).toBeGreaterThan(0)
}),
)
})
@@ -1,66 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src"
import { OpenAI } from "../../src/providers"
import { recordedTests } from "../recorded-test"
const openai = OpenAI.configure({
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
})
const recorded = recordedTests({
prefix: "openai-responses-images",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
})
describe("OpenAI Responses image generation recorded", () => {
recorded.effect("generates and edits an image with the hosted tool", () =>
Effect.gen(function* () {
const initial = Message.user("Generate a simple flat black triangle centered on a plain white background.")
const tools = [
OpenAI.imageGeneration({
action: "auto",
quality: "low",
size: "1024x1024",
outputFormat: "jpeg",
outputCompression: 10,
partialImages: 0,
}),
]
const response = yield* LLM.generate(
LLM.request({
model: openai.responses("gpt-5-mini"),
messages: [initial],
tools,
toolChoice: "image_generation",
}),
)
const result = response.events.find(LLMEvent.is.toolResult)
expect(result).toBeDefined()
expect(result?.providerExecuted).toBe(true)
expect(result?.result.type).toBe("content")
if (result?.result.type !== "content") return
expect(result.result.value).toHaveLength(1)
expect(result.result.value[0]?.type).toBe("file")
if (result.result.value[0]?.type !== "file") return
expect(result.result.value[0].mime).toBe("image/jpeg")
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
const edited = yield* LLM.generate(
LLM.request({
model: openai.responses("gpt-5-mini"),
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
tools,
toolChoice: "image_generation",
}),
)
const editedResult = edited.events.find(LLMEvent.is.toolResult)
expect(editedResult?.result.type).toBe("content")
if (editedResult?.result.type !== "content") return
expect(editedResult.result.value[0]?.type).toBe("file")
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -58,39 +58,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
tools: [OpenAI.imageGeneration({ action: "generate", quality: "high", size: "1024x1024" })],
toolChoice: "image_generation",
}),
)
expect(prepared.body.tools).toEqual([
{ type: "image_generation", action: "generate", quality: "high", size: "1024x1024" },
])
expect(prepared.body.tool_choice).toEqual({ type: "image_generation" })
}),
)
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
tools: [OpenAI.imageGeneration({ outputCompression: -1, partialImages: 4, size: "bogus" })],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("image generation tool options are invalid")
}),
)
it.effect("lowers semantic service tier options", () =>
Effect.gen(function* () {
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
@@ -1136,48 +1103,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.user("Generate a black triangle."),
Message.assistant([
ToolCallPart.make({
id: "ig_1",
name: "image_generation",
input: {},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
ToolResultPart.make({
id: "ig_1",
name: "image_generation",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
]),
Message.user("Make it blue."),
],
tools: [imageTool],
}),
)
expect(prepared.body.store).toBe(false)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Generate a black triangle." }] },
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
{ role: "user", content: [{ type: "input_text", text: "Make it blue." }] },
])
}),
)
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -1436,59 +1361,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () {
const item = {
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
providerExecuted: true,
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
})
}),
)
it.effect("rejects malformed image generation base64", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "image_generation_call", id: "ig_bad", status: "completed", result: "%%%" },
},
{ type: "response.completed", response: {} },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("invalid image base64")
}),
)
it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
Effect.gen(function* () {
const item = {
+1 -99
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src"
import { LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
@@ -53,102 +53,4 @@ describe("OpenRouter", () => {
})
}),
)
it.effect("preserves manually supplied reasoning details", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant([
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
},
]),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning: "Thinking",
reasoning_details: details,
},
])
}),
)
it.effect("preserves opaque and duplicate continuation details", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant({
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
])
}),
)
it.effect("does not merge distinct adjacent reasoning text blocks", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant({
type: "reasoning",
text: "AB",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
])
}),
)
it.effect("omits scalar reasoning without continuation details", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
}),
)
})
+23 -2
View File
@@ -120,8 +120,29 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
throw new Error("Weather tool loop exceeded 10 steps")
})
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
const content: ContentPart[] = []
for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") {
const type = event.type === "text-delta" ? "text" : "reasoning"
const last = content.at(-1)
if (last?.type === type) {
content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
} else {
content.push({ type, text: event.text })
}
continue
}
if (event.type === "text-end" || event.type === "reasoning-end") {
const type = event.type === "text-end" ? "text" : "reasoning"
const last = content.at(-1)
if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
continue
}
if (event.type === "tool-call") content.push(event)
}
return content
}
export const expectFinish = (
events: ReadonlyArray<LLMEvent>,
+2 -8
View File
@@ -3,8 +3,6 @@ import { Layer } from "effect"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
import { ImageClient } from "../src/image-client"
import type { Service as ImageClientService } from "../src/image-client"
import type { Service as LLMClientService } from "../src/route/client"
import type { Service as RequestExecutorService } from "../src/route/executor"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
@@ -17,7 +15,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -83,10 +81,6 @@ export const recordedTests = (options: RecordedTestsOptions) =>
),
)
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll(
deps,
LLMClient.layer.pipe(Layer.provide(deps)),
ImageClient.layer.pipe(Layer.provide(deps)),
)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
},
})
-27
View File
@@ -36,33 +36,6 @@ describe("ToolStream", () => {
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query"' },
"missing tool",
)
if (ToolStream.isError(first)) return yield* first
const second = ToolStream.appendOrStart(
ADAPTER,
first.tools,
0,
{ id: "", name: "", text: ':"weather"}' },
"missing tool",
)
if (ToolStream.isError(second)) return yield* second
const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
}),
)
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
@@ -1615,7 +1615,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
data-background-surface="prompt"
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
@@ -446,7 +446,6 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
return (
<button
data-action="prompt-project"
data-background-surface="project-selector"
type="button"
class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => props.controller.add()}
@@ -465,7 +464,6 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
<button
{...rest}
data-action="prompt-project"
data-background-surface="project-selector"
type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{
@@ -4,14 +4,10 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div
data-component="session-new-design"
data-background-surface="shell"
class="relative size-full overflow-hidden bg-v2-background-bg-deep "
>
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse [&>g>g>g]:!opacity-[0.45]" />
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div>
</div>
</div>
@@ -1,36 +0,0 @@
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { showToast } from "@/utils/toast"
export function useSettingsBackgroundImage() {
const platform = usePlatform()
const language = useLanguage()
const [state, setState] = createStore({ busy: false })
const run = async (action: (() => Promise<unknown>) | undefined) => {
if (!action || state.busy) return
setState("busy", true)
try {
await action()
} catch (error) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
} finally {
setState("busy", false)
}
}
return {
available: !!platform.selectBackgroundImage,
active: () => platform.backgroundImage?.() ?? false,
get busy() {
return state.busy
},
select: () => run(platform.selectBackgroundImage),
clear: () => run(platform.clearBackgroundImage),
}
}
@@ -31,7 +31,6 @@ import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
import { useSettingsBackgroundImage } from "./settings-background-image"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
@@ -88,7 +87,6 @@ export const SettingsGeneral: Component = () => {
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const backgroundImage = useSettingsBackgroundImage()
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
@@ -501,24 +499,6 @@ export const SettingsGeneral: Component = () => {
/>
</SettingsRow>
<Show when={backgroundImage.available}>
<SettingsRow
title={language.t("settings.general.row.backgroundImage.title")}
description={language.t("settings.general.row.backgroundImage.description")}
>
<div class="flex items-center gap-2">
<Button size="small" variant="secondary" disabled={backgroundImage.busy} onClick={backgroundImage.select}>
{language.t("settings.general.row.backgroundImage.choose")}
</Button>
<Show when={backgroundImage.active()}>
<Button size="small" variant="ghost" disabled={backgroundImage.busy} onClick={backgroundImage.clear}>
{language.t("settings.general.row.backgroundImage.remove")}
</Button>
</Show>
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
@@ -29,7 +29,6 @@ import { Link } from "../link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import { useSettingsBackgroundImage } from "../settings-background-image"
import "./settings-v2.css"
let demoSoundState = {
@@ -89,7 +88,6 @@ export const SettingsGeneralV2: Component<{
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const backgroundImage = useSettingsBackgroundImage()
const dialog = useDialog()
const settings = useSettings()
const serverSync = useServerSync()
@@ -462,29 +460,6 @@ export const SettingsGeneralV2: Component<{
/>
</SettingsRowV2>
<Show when={backgroundImage.available}>
<SettingsRowV2
title={language.t("settings.general.row.backgroundImage.title")}
description={language.t("settings.general.row.backgroundImage.description")}
>
<div class="flex items-center gap-2">
<ButtonV2
size="normal"
variant="neutral"
disabled={backgroundImage.busy}
onClick={backgroundImage.select}
>
{language.t("settings.general.row.backgroundImage.choose")}
</ButtonV2>
<Show when={backgroundImage.active()}>
<ButtonV2 size="normal" variant="ghost" disabled={backgroundImage.busy} onClick={backgroundImage.clear}>
{language.t("settings.general.row.backgroundImage.remove")}
</ButtonV2>
</Show>
</div>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
-1
View File
@@ -227,7 +227,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return (
<header
data-background-surface="shell"
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
classList={{
"shrink-0 relative flex flex-row": true,
-12
View File
@@ -112,18 +112,6 @@ type PlatformBase = {
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>
/** Load and apply the saved app background image. */
loadBackgroundImage?(): Promise<boolean>
/** Whether the app currently has a background image. */
backgroundImage?: Accessor<boolean>
/** Select and apply an app background image. */
selectBackgroundImage?(): Promise<boolean>
/** Clear the saved app background image. */
clearBackgroundImage?(): Promise<void>
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>
-37
View File
@@ -1,7 +1,6 @@
// @refresh reload
import * as Sentry from "@sentry/solid"
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
@@ -9,12 +8,6 @@ import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server"
import {
clearWebBackgroundImage,
loadWebBackgroundImage,
saveWebBackgroundImage,
selectWebBackgroundImage,
} from "@/utils/web-background-image"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
@@ -126,21 +119,6 @@ const clearAuthToken = () => {
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
const [backgroundImage, setBackgroundImage] = createSignal(false)
let backgroundImageUrl: string | undefined
const applyBackgroundImage = (image: Blob | null) => {
if (backgroundImageUrl) URL.revokeObjectURL(backgroundImageUrl)
backgroundImageUrl = image ? URL.createObjectURL(image) : undefined
setBackgroundImage(!!backgroundImageUrl)
document.documentElement.toggleAttribute("data-background-image", !!backgroundImageUrl)
if (backgroundImageUrl) {
document.documentElement.style.setProperty("--app-background-image", `url("${backgroundImageUrl}")`)
return true
}
document.documentElement.style.removeProperty("--app-background-image")
return false
}
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -154,23 +132,8 @@ const platform: Platform = {
return stored ? ServerConnection.Key.make(stored) : null
},
setDefaultServer: writeDefaultServerUrl,
backgroundImage,
async loadBackgroundImage() {
return applyBackgroundImage(await loadWebBackgroundImage())
},
async selectBackgroundImage() {
const file = await selectWebBackgroundImage()
if (!file) return backgroundImage()
return applyBackgroundImage(await saveWebBackgroundImage(file))
},
async clearBackgroundImage() {
await clearWebBackgroundImage()
applyBackgroundImage(null)
},
}
void platform.loadBackgroundImage?.()
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
-4
View File
@@ -704,10 +704,6 @@ export const dict = {
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
"settings.general.row.terminalFont.title": "خط الطرفية",
"settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة",
-4
View File
@@ -713,10 +713,6 @@ export const dict = {
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
"settings.general.row.terminalFont.title": "Fonte do terminal",
"settings.general.row.terminalFont.description": "Personalize a fonte usada no terminal",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Fonte da interface",
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
"settings.general.row.followup.title": "Comportamento de acompanhamento",
-4
View File
@@ -778,10 +778,6 @@ export const dict = {
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
"settings.general.row.terminalFont.title": "Font terminala",
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI font",
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
-4
View File
@@ -773,10 +773,6 @@ export const dict = {
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
"settings.general.row.terminalFont.title": "Terminalskrifttype",
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI-skrifttype",
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
"settings.general.row.followup.title": "Opfølgningsadfærd",
-4
View File
@@ -724,10 +724,6 @@ export const dict = {
"settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
"settings.general.row.terminalFont.title": "Terminalschriftart",
"settings.general.row.terminalFont.description": "Passe die im Terminal verwendete Schriftart an",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI-Schriftart",
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
-4
View File
@@ -862,10 +862,6 @@ export const dict = {
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
"settings.general.row.theme.title": "Theme",
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.font.title": "Code Font",
"settings.general.row.font.description": "Customise the font used in code blocks",
"settings.general.row.terminalFont.title": "Terminal Font",
-4
View File
@@ -781,10 +781,6 @@ export const dict = {
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
"settings.general.row.terminalFont.title": "Fuente del terminal",
"settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el terminal",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Fuente de la interfaz",
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
"settings.general.row.followup.title": "Comportamiento de seguimiento",
-4
View File
@@ -720,10 +720,6 @@ export const dict = {
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
"settings.general.row.terminalFont.title": "Police du terminal",
"settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le terminal",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Police de l'interface",
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
"settings.general.row.followup.title": "Comportement de suivi",
-4
View File
@@ -709,10 +709,6 @@ export const dict = {
"settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします",
"settings.general.row.terminalFont.title": "ターミナルのフォント",
"settings.general.row.terminalFont.description": "ターミナルで使用するフォントをカスタマイズ",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UIフォント",
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作",
-4
View File
@@ -580,10 +580,6 @@ export const dict = {
"settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
"settings.general.row.terminalFont.title": "터미널 글꼴",
"settings.general.row.terminalFont.description": "터미널에서 사용할 글꼴을 설정합니다",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI 글꼴",
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작",
-4
View File
@@ -654,10 +654,6 @@ export const dict = {
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
"settings.general.row.terminalFont.title": "Terminalskrift",
"settings.general.row.terminalFont.description": "Tilpass skrifttypen som brukes i terminalen",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI-skrift",
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
"settings.general.row.followup.title": "Oppfølgingsadferd",
-4
View File
@@ -714,10 +714,6 @@ export const dict = {
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu",
"settings.general.row.terminalFont.title": "Czcionka terminala",
"settings.general.row.terminalFont.description": "Dostosuj czcionkę używaną w terminalu",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Czcionka interfejsu",
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
"settings.general.row.followup.title": "Zachowanie kontynuacji",
-4
View File
@@ -778,10 +778,6 @@ export const dict = {
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода",
"settings.general.row.terminalFont.title": "Шрифт терминала",
"settings.general.row.terminalFont.description": "Настройте шрифт, используемый в терминале",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Шрифт интерфейса",
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
-4
View File
@@ -771,10 +771,6 @@ export const dict = {
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
"settings.general.row.terminalFont.title": "ฟอนต์เทอร์มินัล",
"settings.general.row.terminalFont.description": "ปรับแต่งฟอนต์ที่ใช้ในเทอร์มินัล",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "ฟอนต์ UI",
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
-4
View File
@@ -784,10 +784,6 @@ export const dict = {
"settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
"settings.general.row.terminalFont.title": "Terminal yazı tipi",
"settings.general.row.terminalFont.description": "Terminalde kullanılan yazı tipini özelleştirin",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
"settings.general.row.followup.title": "Takip davranışı",
-4
View File
@@ -870,10 +870,6 @@ export const dict = {
"settings.general.row.font.description": "Налаштуйте шрифт, який використовується в блоках коду",
"settings.general.row.terminalFont.title": "Шрифт термінала",
"settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Шрифт інтерфейсу",
"settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі",
"settings.general.row.followup.title": "Поведінка продовження",
-4
View File
@@ -768,10 +768,6 @@ export const dict = {
"settings.general.row.font.description": "自定义代码块使用的字体",
"settings.general.row.terminalFont.title": "终端字体",
"settings.general.row.terminalFont.description": "自定义终端使用的字体",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "界面字体",
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
"settings.general.row.followup.title": "跟进消息行为",
-4
View File
@@ -763,10 +763,6 @@ export const dict = {
"settings.general.row.font.description": "自訂程式碼區塊使用的字型",
"settings.general.row.terminalFont.title": "終端機字型",
"settings.general.row.terminalFont.description": "自訂終端機使用的字型",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "介面字型",
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
"settings.general.row.followup.title": "後續追問行為",
-56
View File
@@ -3,62 +3,6 @@
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-css";
html[data-background-image] body {
background-image: linear-gradient(rgb(0 0 0 / 30%), rgb(0 0 0 / 30%)), var(--app-background-image);
background-color: transparent !important;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
html[data-background-image] #root {
background-color: transparent !important;
}
html[data-background-image] [data-background-surface="shell"] {
background-color: transparent !important;
}
html[data-background-image] [data-background-surface="content"] {
background-color: color-mix(in srgb, var(--background-base) 68%, transparent) !important;
}
html[data-background-image] [data-background-surface="panel"] {
background-color: color-mix(in srgb, var(--v2-background-bg-base) 56%, transparent) !important;
}
html[data-background-image] [data-background-surface="prompt"] {
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
backdrop-filter: blur(16px);
}
html[data-background-image] [data-background-surface="project-selector"] {
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--v2-border-border-base) 70%, transparent);
backdrop-filter: blur(16px);
}
html[data-background-image] [data-background-surface="prompt"] [data-background-surface="project-selector"] {
background-color: transparent !important;
box-shadow: none;
backdrop-filter: none;
}
html[data-background-image] [data-background-surface="workspace-bar"] {
padding-inline: 6px;
padding-block: 2px;
border-radius: 6px;
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--v2-border-border-base) 70%, transparent);
backdrop-filter: blur(16px);
}
html[data-background-image] [data-background-surface="workspace-bar"] [data-background-surface="project-selector"] {
background-color: transparent !important;
box-shadow: none;
backdrop-filter: none;
}
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
+1 -4
View File
@@ -598,10 +598,7 @@ export function NewHome() {
}
return (
<div
data-background-surface="panel"
class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1"
>
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<ScrollView
class="h-full [container-type:size]"
thumbContainer={sessionThumbTrack}
-1
View File
@@ -26,7 +26,6 @@ export default function NewLayout(props: ParentProps) {
return (
<div
data-background-surface="shell"
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
+1 -5
View File
@@ -2246,10 +2246,7 @@ export default function LegacyLayout(props: ParentProps) {
)
return (
<div
data-background-surface="shell"
class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
>
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
@@ -2347,7 +2344,6 @@ export default function LegacyLayout(props: ParentProps) {
}}
>
<main
data-background-surface="content"
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
-2
View File
@@ -180,11 +180,9 @@ export default function NewSessionPage() {
/>
<Show when={projectController.selected()}>
<div
data-background-surface={showWorkspaceBar() ? "workspace-bar" : undefined}
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
classList={{
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
"w-fit max-w-full self-center": showWorkspaceBar(),
"justify-start": !showWorkspaceBar(),
}}
>
-1
View File
@@ -335,7 +335,6 @@ function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
return (
<div
data-background-surface={props.newLayout ? "panel" : undefined}
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": props.newLayout,
@@ -1,33 +0,0 @@
const cacheName = "opencode-background-image-v1"
const maxBytes = 20 * 1024 * 1024
function key() {
return new URL("/__opencode/background-image", location.origin).toString()
}
export async function loadWebBackgroundImage() {
const response = await (await caches.open(cacheName)).match(key())
return response?.blob() ?? null
}
export async function saveWebBackgroundImage(file: File) {
if (!file.type.startsWith("image/")) throw new Error("Unsupported background image format")
if (file.size > maxBytes) throw new Error("Background images must be 20 MB or smaller")
await (await caches.open(cacheName)).put(key(), new Response(file, { headers: { "Content-Type": file.type } }))
return file
}
export async function clearWebBackgroundImage() {
await (await caches.open(cacheName)).delete(key())
}
export function selectWebBackgroundImage() {
return new Promise<File | null>((resolve) => {
const input = document.createElement("input")
input.type = "file"
input.accept = "image/avif,image/bmp,image/gif,image/jpeg,image/png,image/webp"
input.onchange = () => resolve(input.files?.[0] ?? null)
input.oncancel = () => resolve(null)
input.click()
})
}
+2 -13
View File
@@ -1,9 +1,7 @@
import { Context, Effect, FileSystem, Option } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { Config } from "../../config"
import { resolve } from "@opencode-ai/tui/config"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
@@ -11,17 +9,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server)
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
const config = yield* Config.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const service = server.service
yield* Effect.promise(() =>
runMini({
server: {
endpoint: server.endpoint,
reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined,
},
server,
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
@@ -31,7 +21,6 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
tuiConfig: resolved,
}),
)
}),
+42 -9
View File
@@ -1,9 +1,8 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import fs from "node:fs"
import { readFile } from "node:fs/promises"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
@@ -15,17 +14,50 @@ export type InteractiveStdin = {
}
type MiniHost = MiniFrontendInput["host"]
type ModelState = Record<string, unknown> & {
variant?: Record<string, string | undefined>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function state(value: unknown): ModelState {
if (!isRecord(value)) return {}
const variant = isRecord(value.variant)
? Object.fromEntries(
Object.entries(value.variant).flatMap(([key, item]) =>
typeof item === "string" ? ([[key, item]] as const) : [],
),
)
: undefined
return { ...value, variant }
}
function variantKey(model: NonNullable<MiniFrontendInput["model"]>) {
return `${model.providerID}/${model.modelID}`
}
function preferences(statePath: string): MiniHost["preferences"] {
const repository = createModelPreferenceRepository(path.join(statePath, "model.json"))
const file = path.join(statePath, "model.json")
const read = () =>
readFile(file, "utf8")
.then((value) => state(JSON.parse(value)))
.catch(() => state(undefined))
return {
async resolveVariant(model) {
if (!model) return
return repository.resolveVariant(model)
return (await read()).variant?.[variantKey(model)]
},
async saveVariant(model, variant) {
if (!model) return
await repository.saveVariant(model, variant).catch(() => undefined)
const current = await read()
const next = { ...current.variant }
if (variant) next[variantKey(model)] = variant
if (!variant) delete next[variantKey(model)]
await mkdir(path.dirname(file), { recursive: true })
.then(() => writeFile(file, JSON.stringify({ ...current, variant: next }, null, 2)))
.catch(() => {})
},
}
}
@@ -46,7 +78,7 @@ function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
function createTrace(
logPath: string,
diagnostics: { pid: number; cwd: string; argv: string[] },
diagnostics: Pick<MiniHost["diagnostics"], "pid" | "cwd" | "argv">,
): MiniHost["diagnostics"]["trace"] {
if (!process.env.OPENCODE_DIRECT_TRACE) return
const stamp = new Date()
@@ -130,7 +162,7 @@ export async function usingInteractiveStdin<T>(
export function createMiniHost(input: {
terminal: InteractiveStdin
directory: string
paths?: { home: string; state: string; log: string }
paths?: MiniHost["paths"]
}): MiniHost {
const paths = input.paths ?? {
home: Global.Path.home,
@@ -143,7 +175,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
terminal: { stdin: input.terminal.stdin },
terminal: input.terminal,
platform: process.platform,
stdout: {
write(value) {
@@ -159,7 +191,7 @@ export function createMiniHost(input: {
return openEditor(options)
},
},
paths: { home: paths.home },
paths,
signals: {
sigint: signal("SIGINT"),
sigusr2: signal("SIGUSR2"),
@@ -169,6 +201,7 @@ export function createMiniHost(input: {
now: () => performance.now(),
},
diagnostics: {
...diagnostics,
trace: createTrace(paths.log, diagnostics),
},
preferences: preferences(paths.state),
+70 -165
View File
@@ -1,17 +1,14 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { setTimeout } from "node:timers/promises"
import { ServerConnection } from "./services/server-connection"
import { waitForCatalogReady } from "./services/catalog"
import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
export type MiniCommandInput = {
server: {
endpoint: Endpoint
reconnect?: (signal: AbortSignal) => Promise<Endpoint>
}
server: ServerConnection.Resolved
continue?: boolean
session?: string
fork?: boolean
@@ -24,6 +21,7 @@ export type MiniCommandInput = {
tuiConfig?: MiniFrontendInput["tuiConfig"]
}
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
type Model = MiniFrontendInput["model"]
class MiniInputError extends Error {}
@@ -35,86 +33,42 @@ export async function runMini(input: MiniCommandInput) {
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
const frontendTask = import("@opencode-ai/tui/mini")
const directory = localDirectory()
const connection = createMiniConnection(input.server)
const sdk = connection.sdk
const requested = parseModel(input.model)
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
const prepare = prepareTarget(input.agent)
const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => {
const resolved = await resolveMiniTarget({
sdk: initial,
reconnect: connection.reconnect,
signal,
resolve: (client) =>
resolveSessionTarget({
client,
location: { directory },
continue: input.continue,
session: input.session,
fork: input.fork,
model: requested,
agent: input.agent,
prepare,
signal,
}).catch((error) => {
if (error instanceof Error && error.message === "Session not found")
throw new MiniInputError(error.message)
throw error
}),
})
const target = resolved.value
return {
sdk: resolved.sdk,
sessionID: target.session.id,
sessionTitle: target.session.title,
location: target.location,
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
variant: target.model?.variant,
agent: target.agent,
resume: target.resume,
}
const sdk = OpenCode.make({
baseUrl: input.server.endpoint.url,
headers: Service.headers(input.server.endpoint),
})
const model = parseModel(input.model)
let agentTask: Promise<string | undefined> | undefined
const resolveAgent = () => {
agentTask ??= validateAgent(sdk, directory, input.agent)
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
const session = selected ?? (await createSession(sdk, directory, agent, model))
return { id: session.id, title: session.title, resume: selected !== undefined }
}
const create = (
client: OpenCodeClient,
next: {
location: { directory: string; workspaceID?: string }
agent: string | undefined
model: Model
variant: string | undefined
},
signal?: AbortSignal,
) =>
resolveSessionTarget({
client,
location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
: undefined,
prepare,
signal,
}).then((target) => ({
sessionID: target.session.id,
sessionTitle: target.session.title,
location: target.location,
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
variant: target.model?.variant,
agent: target.agent,
resume: false,
}))
_sdk: OpenCodeClient,
next: { agent: string | undefined; model: Model; variant: string | undefined },
) => createSession(sdk, directory, next.agent, next.model, next.variant)
const frontend = await frontendTask
return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory }),
sdk,
directory,
target: resolveTarget,
reconnect: connection.reconnect,
resolveAgent,
session: resolveSession,
createSession: create,
agent: input.agent,
model,
variant: requested?.variant,
variant: undefined,
files: [],
initialInput,
thinking: true,
replay: input.replay ?? true,
replayLimit: input.replayLimit,
demo: input.demo,
@@ -129,51 +83,6 @@ export async function runMini(input: MiniCommandInput) {
}
}
/** @internal Exported for CLI boundary tests. */
export function createMiniConnection(input: MiniCommandInput["server"]) {
const make = (endpoint: Endpoint) =>
OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const reconnect = input.reconnect
return {
sdk: make(input.endpoint),
reconnect: reconnect
? async (signal: AbortSignal) => {
const endpoint = await reconnect(signal)
return make(endpoint)
}
: undefined,
}
}
/** @internal Exported for reconnect lifecycle tests. */
export async function resolveMiniTarget<A>(input: {
sdk: OpenCodeClient
reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
signal: AbortSignal
resolve: (sdk: OpenCodeClient) => Promise<A>
}) {
let sdk = input.sdk
while (true) {
try {
return { sdk, value: await input.resolve(sdk) }
} catch (error) {
if (!input.reconnect || !(error instanceof ClientError) || error.reason !== "Transport") throw error
while (true) {
try {
sdk = await input.reconnect(input.signal)
break
} catch (resolveError) {
if (input.signal.aborted) throw resolveError
await setTimeout(250, undefined, { signal: input.signal })
}
}
}
}
}
export function validateMiniTerminal() {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
}
@@ -203,63 +112,28 @@ function localDirectory(): string {
}
}
function parseModel(value?: string) {
try {
return parseSessionTargetModel(value)
} catch {
throw new MiniInputError("--model must use the format provider/model[#variant]")
}
function parseModel(value?: string): Model {
if (!value) return
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) throw new MiniInputError("--model must use the format provider/model")
return { providerID, modelID }
}
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
return async (input) => {
if (input.model)
await waitForCatalogReady({
sdk: input.client,
directory: input.location.directory,
workspace: input.location.workspaceID,
model: { providerID: input.model.providerID, modelID: input.model.id },
signal: input.signal,
})
return {
model: input.model,
agent: requestedAgent
? await validateAgent(
input.client,
input.location.directory,
input.location.workspaceID,
requestedAgent,
input.signal,
)
: input.agent,
}
}
}
async function validateAgent(
sdk: OpenCodeClient,
directory: string,
workspace: string | undefined,
name?: string,
signal?: AbortSignal,
) {
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline && !signal?.aborted) {
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
while (Date.now() < deadline) {
agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
await setTimeout(25, undefined, { signal }).catch(() => {})
await setTimeout(25)
}
if (signal?.aborted) return
if (!agents) {
warning("failed to list agents. Falling back to default agent")
return
@@ -267,6 +141,37 @@ async function validateAgent(
warning(`agent "${name}" not found. Falling back to default agent`)
}
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
const selected =
preselected ??
(input.session
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await sdk.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined)
if (input.session && !selected) throw new MiniInputError("Session not found")
if (!selected) return
if (!input.fork) return selected
return sdk.session.fork({ sessionID: selected.id })
}
async function createSession(
sdk: OpenCodeClient,
directory: string,
agent: string | undefined,
model: Model,
variant?: string,
): Promise<Session> {
if (model) await waitForCatalogReady({ sdk, directory, model })
return sdk.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory },
})
}
function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
}
+45 -146
View File
@@ -1,11 +1,4 @@
import type {
EventSubscribeOutput,
JsonValue,
LLMToolContent,
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
} from "@opencode-ai/client/promise"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
@@ -26,7 +19,6 @@ type File = {
type Input = {
client: OpenCodeClient
sessionID: string
location: LocationRef
message: string
files: File[]
agent?: string
@@ -38,8 +30,8 @@ type Input = {
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean
compatibility?: "v1"
renderTool: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError: (part: SessionMessageAssistantTool) => Promise<void>
renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: MiniToolPart) => Promise<void>
}
type StartedPart = {
@@ -50,12 +42,9 @@ type StartedPart = {
type ToolState = StartedPart & {
assistantMessageID: string
tool: string
input: Record<string, JsonValue>
input: Record<string, unknown>
raw?: string
provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
structured: Record<string, JsonValue>
content: LLMToolContent[]
}
type V2Event = EventSubscribeOutput
@@ -78,6 +67,7 @@ export async function runNonInteractivePrompt(input: Input) {
let submitted = false
let promoted = false
let emittedError = false
let questionRejected = false
let permissionRejected = false
let formCancelled = false
let interrupted = false
@@ -136,16 +126,14 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const rejectQuestion = async (request: { id: string }) => {
questionRejected = true
await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
}
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
try {
await input.client.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
formCancelled = true
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
}
const consume = async () => {
@@ -164,13 +152,15 @@ export async function runNonInteractivePrompt(input: Input) {
await replyPermission(event.data)
continue
}
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await rejectQuestion(event.data)
continue
}
if (
event.type === "form.created" &&
submitted &&
(event.data.form.sessionID === input.sessionID ||
(!input.attached &&
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
(!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID))
) {
await cancelForm(event.data.form)
continue
@@ -187,7 +177,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (
event.type === "session.execution.interrupted" &&
event.data.reason === "user" &&
(interrupted || permissionRejected || formCancelled)
(interrupted || permissionRejected || questionRejected || formCancelled)
) {
return
}
@@ -270,32 +260,24 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.input.started") {
flushStep()
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
tools.set(event.data.callID, {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
structured: {},
content: [],
})
continue
}
if (event.type === "session.tool.input.ended") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
const current = tools.get(event.data.callID)
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.tool.input.delta") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = (current.raw ?? "") + event.data.delta
continue
}
if (event.type === "session.tool.called") {
flushStep()
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key)
tools.set(key, {
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID,
@@ -303,39 +285,11 @@ export async function runNonInteractivePrompt(input: Input) {
input: event.data.input,
raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state },
providerState: event.data.state,
structured: {},
content: [],
})
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) {
current.structured = event.data.structured
current.content = event.data.content
}
continue
}
if (event.type === "session.tool.success") {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
providerResultState: event.data.resultState,
state: {
status: "completed",
input: current.input,
structured: event.data.structured,
content: event.data.content,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
@@ -359,31 +313,13 @@ export async function runNonInteractivePrompt(input: Input) {
time: { start: current.timestamp, end: time },
},
}
tools.delete(key)
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue
}
if (event.type === "session.tool.failed") {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
providerResultState: event.data.resultState,
state: {
status: "error",
input: current.input,
structured: current.structured,
content: current.content,
error: event.data.error,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
@@ -404,21 +340,10 @@ export async function runNonInteractivePrompt(input: Input) {
time: { start: current.timestamp, end: time },
},
}
tools.delete(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
tools.delete(event.data.callID)
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, current.content).trim())
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured: current.structured,
content: current.content,
result: event.data.result,
},
})
await input.renderToolError(tool)
await input.renderToolError(part)
UI.error(error)
}
continue
@@ -448,7 +373,7 @@ export async function runNonInteractivePrompt(input: Input) {
v1InvalidOutput = true
continue
}
if (interrupted || permissionRejected || formCancelled) continue
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
flushStep()
emittedError = true
process.exitCode = 1
@@ -456,9 +381,13 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.execution.failed") {
if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return
if (
input.compatibility === "v1" &&
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
)
return
flushStep()
if (!emittedError && !formCancelled) {
if (!emittedError && !questionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
@@ -466,7 +395,7 @@ export async function runNonInteractivePrompt(input: Input) {
return
}
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
@@ -541,23 +470,19 @@ export async function runNonInteractivePrompt(input: Input) {
if (!response) return
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, forms, globals] = await Promise.all([
const [permissions, questions, forms] = await Promise.all([
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached
? Promise.resolve(undefined)
: input.client.form.request
.list({
location: { directory: input.location.directory, workspace: input.location.workspaceID },
})
.catch(() => undefined),
input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
Promise.all(
(input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
input.client.form.list({ sessionID }).catch(() => undefined),
),
),
])
await Promise.all([
...(permissions ?? []).map(replyPermission),
...(forms ?? []).map(cancelForm),
...(globals && sameLocation(globals.location, input.location)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
: []),
...(questions ?? []).map(rejectQuestion),
...forms.flatMap((response) => response ?? []).map(cancelForm),
])
await completed
} finally {
@@ -567,34 +492,10 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
function sameLocation(left: LocationRef | undefined, right: LocationRef) {
return !!left && left.directory === right.directory && left.workspaceID === right.workspaceID
}
function formRequestOptions(location: LocationRef | undefined): [] | [{ headers: Record<string, string> }] {
if (!location) return []
return [
{
headers: {
"x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
},
},
]
}
function formAlreadySettled(error: unknown) {
return !!error && typeof error === "object" && Reflect.get(error, "_tag") === "FormAlreadySettledError"
}
function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function fallbackTool(event: {
id: string
created: number
@@ -606,8 +507,6 @@ function fallbackTool(event: {
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
structured: {},
content: [],
}
}
+72 -81
View File
@@ -1,13 +1,13 @@
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 } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "../services/catalog"
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { toolInlineInfo, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
import { UI } from "./ui"
@@ -47,15 +47,6 @@ type ExecutionOptions = {
compatibility?: "v1"
}
class RunTargetError extends Error {
constructor(
message: string,
readonly sessionID?: string,
) {
super(message)
}
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
export function runNonInteractive(input: RunCommandInput) {
@@ -81,74 +72,50 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory")
const session = await selectSession(client, requestedDirectory, input)
const cwd = session?.location.directory ?? requestedDirectory
const workspace = session?.location.workspaceID
const explicit = parseRunModel(input.model)
const target = await resolveSessionTarget({
client,
location: prepared.directory ? { directory: prepared.directory } : undefined,
continue: input.continue,
session: input.session,
fork: input.fork,
model: explicit
? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant }
: undefined,
agent: input.agent,
prepare: async (next) => {
const selected =
next.model ??
(await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data))
const model = selected
? {
providerID: selected.providerID,
id: selected.id,
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
}
: undefined
if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
if (model) {
await waitForCatalogReady({
sdk: client,
directory: next.location.directory,
workspace: next.location.workspaceID,
model: { providerID: model.providerID, modelID: model.id },
})
const available = await client.model.list({
location: { directory: next.location.directory, workspace: next.location.workspaceID },
})
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
}
return {
model,
agent: input.agent
? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent)
: next.agent,
}
},
}).catch((error) => {
if (!(error instanceof RunTargetError)) throw error
reportRunError(input, error.message, error.sessionID)
return undefined
})
if (!target) return
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
const variant = target.model?.variant
if (!target.resume && input.title !== undefined) {
const explicitModel = explicit?.model
const variant = options.variant ?? explicit?.variant
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
const defaultModel =
!explicitModel && !sessionModel
? await client.model
.default({ location: { directory: cwd, workspace } })
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
}
const agent = await validateAgent(client, cwd, input.agent)
const selected =
session ??
(await client.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory: cwd },
}))
if (!session && input.title !== undefined) {
await client.session.rename({
sessionID: target.session.id,
sessionID: selected.id,
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
}
await runNonInteractivePrompt({
client,
sessionID: target.session.id,
location: target.location,
sessionID: selected.id,
message: prepared.message,
files: prepared.files,
agent: target.agent,
agent,
model,
variant,
thinking: input.thinking ?? false,
@@ -156,9 +123,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
auto: input.auto ?? false,
attached: options.attached ?? true,
compatibility: options.compatibility,
renderTool: (part) => renderTool(part, target.location.directory),
renderToolError: (part) => renderToolError(part, target.location.directory),
}).catch((error) => reportRunError(input, errorMessage(error), target.session.id))
renderTool,
renderToolError,
}).catch((error) => reportRunError(input, errorMessage(error), selected.id))
}
export function mergeInput(message: string | undefined, piped: string | undefined) {
@@ -167,6 +134,17 @@ export function mergeInput(message: string | undefined, piped: string | undefine
return message + "\n" + piped
}
export function pickRunModel(
explicit: { providerID: string; modelID: string } | undefined,
variant: string | undefined,
session: { providerID: string; modelID: string } | undefined,
fallback: { providerID: string; modelID: string } | undefined,
) {
if (explicit) return explicit
if (!variant) return
return session ?? fallback
}
function formatMessage(message: string[]) {
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
return value || undefined
@@ -182,18 +160,18 @@ function localDirectory(root: string) {
}
export function parseRunModel(value?: string) {
const ref = parseSessionTargetModel(value)
if (!ref) return
if (!value) return
const ref = Model.Ref.parse(value)
return {
model: { providerID: ref.providerID, modelID: ref.id },
variant: ref.variant,
}
}
async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) {
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {
if (!name) return
const agents = await client.agent
.list({ location: { directory, workspace } })
.list({ location: { directory } })
.then((result) => result.data)
.catch(() => undefined)
if (!agents) {
@@ -212,6 +190,19 @@ async function validateAgent(client: OpenCodeClient, directory: string, workspac
return name
}
async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
const selected = input.session
? await client.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await client.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined
if (input.session && !selected) fail("Session not found")
if (!selected || !input.fork) return selected
return client.session.fork({ sessionID: selected.id })
}
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
@@ -253,8 +244,8 @@ function isBinaryContent(bytes: Uint8Array) {
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
}
async function renderTool(part: SessionMessageAssistantTool, directory: string) {
const info = toolInlineInfo(part, directory)
async function renderTool(part: MiniToolPart) {
const info = toolInlineInfo(part)
if (info.mode === "block") {
UI.empty()
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
@@ -269,8 +260,8 @@ async function renderTool(part: SessionMessageAssistantTool, directory: string)
)
}
async function renderToolError(part: SessionMessageAssistantTool, directory: string) {
const info = toolInlineInfo(part, directory)
async function renderToolError(part: MiniToolPart) {
const info = toolInlineInfo(part)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
+5 -26
View File
@@ -1,4 +1,4 @@
import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
// Location plugins initialize asynchronously, so explicit model selection must
// wait for that exact model before prompt admission. The execution path owns
@@ -9,35 +9,14 @@ export async function waitForCatalogReady(input: {
workspace?: string
model: { providerID: string; modelID: string }
timeoutMs?: number
signal?: AbortSignal
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && !input.signal?.aborted) {
while (Date.now() < deadline) {
const models = await input.sdk.model
.list(
{ location: { directory: input.directory, workspace: input.workspace } },
{ signal: input.signal },
)
.list({ location: { directory: input.directory, workspace: input.workspace } })
.then((result) => result.data)
.catch((error) => {
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
.catch(() => undefined)
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
await wait(25, input.signal)
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
function wait(delay: number, signal?: AbortSignal) {
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
if (signal.aborted) return Promise.resolve()
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal?.removeEventListener("abort", done)
resolve()
}
})
}
@@ -56,7 +56,7 @@ function managedService(options: EnsureOptions) {
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.ensure(reconnectOptions)
yield* Service.ensure(options)
}),
}
}
-166
View File
@@ -1,166 +0,0 @@
import type { LocationGetOutput, ModelRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client/promise"
import { Model } from "@opencode-ai/schema/model"
const SESSION_PAGE_LIMIT = 50
export type SessionTarget = {
session: SessionInfo
location: LocationGetOutput
model: ModelRef | undefined
agent: string | undefined
resume: boolean
}
export type SessionTargetPreparation = (input: {
client: OpenCodeClient
location: LocationGetOutput
session: SessionInfo | undefined
model: ModelRef | undefined
agent: string | undefined
signal?: AbortSignal
}) => Promise<{ model: ModelRef | undefined; agent: string | undefined }>
export class SessionTargetMutationError extends Error {
override readonly name = "SessionTargetMutationError"
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : "Session target mutation failed", { cause })
}
}
export async function resolveSessionTarget(input: {
client: OpenCodeClient
location?: { directory?: string; workspace?: string }
continue?: boolean
session?: string
fork?: boolean
model?: ModelRef
agent?: string
prepare: SessionTargetPreparation
signal?: AbortSignal
}): Promise<SessionTarget> {
const selection = await selectSession(input)
const selected = selection.session
const location =
selection.location ??
(await resolveLocation(
input.client,
selected ? { directory: selected.location.directory, workspace: selected.location.workspaceID } : input.location,
input.signal,
))
const prepared = await input.prepare({
client: input.client,
location,
session: selected,
model: input.model ?? selected?.model,
agent: input.agent ?? selected?.agent,
signal: input.signal,
})
const session =
selected ??
(await input.client.session
.create(
{
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory, workspaceID: location.workspaceID },
},
...requestOptions(input.signal),
)
.catch((error) => {
throw new SessionTargetMutationError(error)
}))
return {
session,
location,
model: prepared.model,
agent: prepared.agent,
resume: selected !== undefined,
}
}
export function parseSessionTargetModel(value?: string): ModelRef | undefined {
if (!value) return
const model = Model.Ref.parse(value)
return { providerID: model.providerID, id: model.id, variant: model.variant }
}
async function selectSession(input: {
client: OpenCodeClient
location?: { directory?: string; workspace?: string }
continue?: boolean
session?: string
fork?: boolean
signal?: AbortSignal
}) {
const explicit = input.session
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
if (error && typeof error === "object" && Reflect.get(error, "_tag") === "SessionNotFoundError")
return undefined
throw error
})
: undefined
if (input.session && !explicit) throw new Error("Session not found")
if (explicit)
return {
session: input.fork
? await input.client.session
.fork({ sessionID: explicit.id }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
: explicit,
}
if (!input.continue) return { session: undefined }
const location = await resolveLocation(input.client, input.location, input.signal)
const selected = await latestSession(input.client, location, undefined, input.signal)
if (!selected) return { session: undefined, location }
return {
session: input.fork
? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => {
throw new SessionTargetMutationError(error)
})
: selected,
}
}
async function latestSession(
client: OpenCodeClient,
location: LocationGetOutput,
cursor?: string,
signal?: AbortSignal,
): Promise<SessionInfo | undefined> {
const page = await client.session.list(
{
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
limit: SESSION_PAGE_LIMIT,
order: "desc",
...(cursor ? { cursor } : {}),
},
...requestOptions(signal),
)
const selected = page.data.find(
(session) =>
session.location.directory === location.directory && session.location.workspaceID === location.workspaceID,
)
if (selected) return selected
if (!page.cursor.next || page.data.length === 0) return
return latestSession(client, location, page.cursor.next, signal)
}
function resolveLocation(
client: OpenCodeClient,
location?: { directory?: string; workspace?: string },
signal?: AbortSignal,
) {
if (!location && !signal) return client.location.get()
if (!location) return client.location.get(undefined, { signal })
return client.location.get({ location }, ...requestOptions(signal))
}
function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
return signal ? [{ signal }] : []
}
+119 -149
View File
@@ -1,162 +1,132 @@
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
import { defineScript } from "opencode-drive"
import { mkdir } from "node:fs/promises"
import path from "node:path"
export default defineScript({
launch: "manual",
config: { autoupdate: false },
run: ({ artifacts, llm, server }) =>
Effect.gen(function* () {
yield* Effect.promise(() => configureServicePort(artifacts))
yield* server.launch()
setup({ config }) {
config.autoupdate = false
},
async run({ artifacts, llm, server, signal }) {
await configureServicePort(artifacts)
await server.launch()
const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
const root = path.resolve(import.meta.dir, "../../../..")
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
const registration = await serviceRegistration(artifacts)
const root = path.resolve(import.meta.dir, "../../../..")
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
await mkdir(snapshots, { recursive: true })
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-shell",
name: "shell",
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
Llm.finish("tool-calls"),
llm.queue(
llm.toolCall({
index: 0,
id: "mini-shell",
name: "shell",
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
llm.finish("tool-calls"),
)
llm.queue(llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
const abort = () => {
void tmux(["kill-session", "-t", session], true).catch(() => {})
}
signal.addEventListener("abort", abort, { once: true })
try {
await tmux([
"new-session",
"-d",
"-s",
session,
"-x",
"140",
"-y",
"30",
"--",
"env",
`PWD=${path.join(artifacts, "files")}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
"--preload=@opentui/solid/preload",
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
"--model",
"simulation/gpt-sim-model",
])
await tmux(["set-option", "-t", session, "remain-on-exit", "on"])
const first = await waitForPane(session, "OpenCode")
await Bun.write(path.join(snapshots, "01-first-paint.txt"), first)
if (first.includes("drive mini response complete")) throw new Error("response rendered before prompt submission")
await waitForPane(session, "Simulated Model", 15_000)
await tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"])
await Bun.sleep(100)
await tmux(["send-keys", "-H", "-t", session, "0d"])
const completed = await waitForPane(session, "drive mini response complete", 20_000)
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
await Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed)
await Bun.sleep(500)
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
await tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`])
await tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"])
await waitForFile(
resizeOutput,
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
)
yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
await tmux(["pipe-pane", "-t", session])
const resized = await captureVisiblePane(session)
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
await Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized)
const journey = Effect.gen(function* () {
yield* Effect.uninterruptible(
Effect.promise(() =>
tmux([
"new-session",
"-d",
"-s",
session,
"-x",
"140",
"-y",
"30",
"--",
"env",
`PWD=${path.join(artifacts, "files")}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
"--model",
"simulation/gpt-sim-model",
]),
),
)
yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
llm.queue(
llm.toolCall({
index: 0,
id: "mini-slow-shell",
name: "shell",
input: { command: "sleep 10" },
}),
llm.finish("tool-calls"),
)
await tmux(["send-keys", "-t", session, "-l", "interrupt this turn"])
await Bun.sleep(100)
await tmux(["send-keys", "-H", "-t", session, "0d"])
await waitForPane(session, "$ sleep 10")
await tmux(["send-keys", "-t", session, "Escape"])
const armed = await waitForPane(session, "again to interrupt")
await Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed)
await tmux(["send-keys", "-t", session, "Escape"])
const interrupted = await waitForPane(session, "Step interrupted", 10_000)
await Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted)
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
if (first.includes("drive mini response complete"))
throw new Error("response rendered before prompt submission")
yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
yield* Effect.sleep(500)
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
yield* Effect.promise(() =>
waitForFile(
resizeOutput,
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
),
)
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
const resized = yield* Effect.promise(() => captureVisiblePane(session))
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-question",
name: "question",
input: {
questions: [
{
header: "Drive form",
question: "Choose the Mini Form answer",
options: [{ label: "Accepted", description: "Continue the run" }],
multiple: false,
},
],
},
}),
Llm.finish("tool-calls"),
)
yield* llm.queue(Llm.text("drive mini form complete"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-slow-shell",
name: "shell",
input: { command: "sleep 10" },
}),
Llm.finish("tool-calls"),
)
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
yield* Effect.promise(async () => {
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
})
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
const status = yield* Effect.promise(() => paneDeadStatus(session))
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
const exited = yield* Effect.promise(() => capturePane(session))
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
})
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
}),
await tmux(["send-keys", "-t", session, "C-c"])
await waitForPane(session, "Press ctrl+c again to exit")
await tmux(["send-keys", "-t", session, "C-c"])
await waitForDeadPane(session)
const status = await paneDeadStatus(session)
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
const exited = await capturePane(session)
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
await Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)
} finally {
signal.removeEventListener("abort", abort)
await tmux(["kill-session", "-t", session], true)
}
},
})
/** @param {string[]} args */
+21 -5
View File
@@ -145,11 +145,11 @@ describe("Mini CLI host", () => {
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
})
test("passes frontend host capabilities", async () => {
test("passes paths, platform, timing, and diagnostic context", async () => {
const directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory)
expect(input.paths).toEqual({ home: directory })
expect(input.paths).toEqual({ home: directory, state: directory, log: directory })
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory, "attachment.txt")
@@ -157,21 +157,37 @@ describe("Mini CLI host", () => {
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
expect(typeof input.startup.showTiming).toBe("boolean")
expect(typeof input.startup.now()).toBe("number")
expect(input.diagnostics).toMatchObject({ pid: process.pid, cwd: directory })
})
test("delegates model variant preferences", async () => {
test("merges, clears, and repairs persisted model variants", async () => {
const directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory)
const file = path.join(directory, "model.json")
await Bun.write(
file,
JSON.stringify({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low", invalid: 42 },
}),
)
await input.preferences.saveVariant(model, "high")
expect(await input.preferences.resolveVariant(model)).toBe("high")
expect(await Bun.file(file).json()).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low", "openai/gpt-5": "high" },
})
await input.preferences.saveVariant(model, "default")
await input.preferences.saveVariant(model, undefined)
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
expect(await Bun.file(file).json()).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low" },
})
await Bun.write(file, "{")
await input.preferences.saveVariant(model, "high")
expect(await input.preferences.resolveVariant(model)).toBe("high")
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
})
})
+13 -80
View File
@@ -1,10 +1,8 @@
import { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "node:path"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
import { parseSessionTargetModel } from "../src/session-target"
import { mergeInput as mergeInteractiveInput } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -26,91 +24,26 @@ describe("mini command", () => {
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
})
test("constructs a fresh authenticated client for a replacement endpoint", async () => {
const authorization: Array<string | null> = []
const initial = Bun.serve({
port: 0,
fetch() {
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
},
})
const replacement = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization"))
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
},
})
const controller = new AbortController()
let signal: AbortSignal | undefined
try {
const connection = createMiniConnection({
endpoint: { url: initial.url.toString() },
reconnect: async (next) => {
signal = next
return {
url: replacement.url.toString(),
auth: { type: "basic", username: "replacement", password: "secret" },
}
},
})
const client = await connection.reconnect?.(controller.signal)
if (!client) throw new Error("Expected a replacement client")
await client.health.get()
expect(client).not.toBe(connection.sdk)
expect(signal).toBe(controller.signal)
expect(authorization).toEqual([`Basic ${btoa("replacement:secret")}`])
expect(createMiniConnection({ endpoint: { url: initial.url.toString() } }).reconnect).toBeUndefined()
} finally {
initial.stop(true)
replacement.stop(true)
}
})
test("re-resolves a managed target when the endpoint moves before transport construction", async () => {
const initial = OpenCode.make({ baseUrl: "https://initial.opencode.test" })
const replacement = OpenCode.make({ baseUrl: "https://replacement.opencode.test" })
const controller = new AbortController()
const seen: (typeof initial)[] = []
let reconnects = 0
const result = await resolveMiniTarget({
sdk: initial,
reconnect: async (signal) => {
expect(signal).toBe(controller.signal)
reconnects++
if (reconnects === 1) throw new Error("service still moving")
return replacement
},
signal: controller.signal,
resolve: async (sdk) => {
seen.push(sdk)
if (sdk === initial) throw new ClientError("Transport")
return "ses-replacement"
},
})
expect(seen).toEqual([initial, replacement])
expect(reconnects).toBe(2)
expect(result).toEqual({ sdk: replacement, value: "ses-replacement" })
})
test("merges non-interactive argument and stdin input", () => {
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
})
test("applies a variant to a resumed session's model", () => {
expect(
pickRunModel(
undefined,
"high",
{ providerID: "session-provider", modelID: "session-model" },
{ providerID: "default-provider", modelID: "default-model" },
),
).toEqual({ providerID: "session-provider", modelID: "session-model" })
})
test("parses model variants from the model reference", () => {
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
)
expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({
providerID: "openrouter",
id: "openai/gpt-5",
variant: "high",
})
})
test("is registered in the preview CLI", async () => {
+11 -161
View File
@@ -1,10 +1,9 @@
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 } from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
type V2Event = EventSubscribeOutput
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
const location = { directory: "/work tree", workspaceID: "wrk_1" }
function ok<T>(data: T) {
return Promise.resolve(data)
@@ -19,8 +18,8 @@ function form(id: string, sessionID: string): FormInfo {
}
}
function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
function formCreated(info: FormInfo): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } }
}
function prompted(inputID: string): V2Event {
@@ -93,64 +92,6 @@ function executionFailed(message: string): V2Event {
}
}
function failedTool(inputID: string): V2Event[] {
return [
prompted(inputID),
{
id: "evt_failed_tool_input",
created: 1,
type: "session.tool.input.started",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
name: "shell",
},
},
{
id: "evt_failed_tool_called",
created: 2,
type: "session.tool.called",
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
input: { command: "printf partial && false" },
executed: true,
},
},
{
id: "evt_failed_tool_progress",
created: 3,
type: "session.tool.progress",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
},
},
{
id: "evt_failed_tool_terminal",
created: 4,
type: "session.tool.failed",
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
error: { type: "unknown", message: "tool failed" },
executed: true,
},
},
settled(),
]
}
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: {
@@ -159,9 +100,6 @@ async function run(input: {
attached?: boolean
format?: "default" | "json"
compatibility?: "v1"
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
}) {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
@@ -181,18 +119,10 @@ async function run(input: {
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
spyOn(sdk.form.request, "list").mockImplementation(
() =>
ok({
location: { ...location, project: { id: "proj_1", directory: location.directory } },
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
}) as never,
)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
values.push(...input.turn(messageID))
@@ -203,7 +133,6 @@ async function run(input: {
await runNonInteractivePrompt({
client: sdk,
sessionID: "ses_1",
location,
message: "hello",
files: [],
thinking: false,
@@ -211,8 +140,8 @@ async function run(input: {
auto: false,
attached: input.attached ?? false,
compatibility: input.compatibility,
renderTool: input.renderTool ?? (() => Promise.resolve()),
renderToolError: input.renderToolError ?? (() => Promise.resolve()),
renderTool: () => Promise.resolve(),
renderToolError: () => Promise.resolve(),
})
return sdk
}
@@ -251,20 +180,9 @@ describe("runNonInteractivePrompt", () => {
// which must not leave the consume loop waiting forever.
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
})
const globalOptions = {
headers: {
"x-opencode-directory": "%2Fwork%20tree",
"x-opencode-workspace": "wrk_1",
},
}
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree", workspace: "wrk_1" },
})
expect(sdk.question.list).not.toHaveBeenCalled()
expect(sdk.question.reject).not.toHaveBeenCalled()
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
})
test("attach mode cancels only session-owned forms", async () => {
@@ -274,12 +192,9 @@ describe("runNonInteractivePrompt", () => {
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
})
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.request.list).not.toHaveBeenCalled()
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
})
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
@@ -335,69 +250,4 @@ describe("runNonInteractivePrompt", () => {
expect(output).toEqual({ stdout: "", stderr: "" })
})
test("renders native failed tool output before the terminal error", async () => {
const rendered: SessionMessageAssistantTool[] = []
const failed: SessionMessageAssistantTool[] = []
await capture({
turn: failedTool,
renderTool: (part) => {
rendered.push(part)
return Promise.resolve()
},
renderToolError: (part) => {
failed.push(part)
return Promise.resolve()
},
})
expect(rendered).toMatchObject([
{
id: "call_failed_tool",
state: {
status: "completed",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
},
},
])
expect(failed).toMatchObject([
{
id: "call_failed_tool",
state: {
status: "error",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
error: { message: "tool failed" },
},
},
])
})
test("keeps failed tool partial output out of the explicit V1 JSON bridge shape", async () => {
const output = await capture({ compatibility: "v1", format: "json", turn: failedTool })
const events = output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
type: "tool_use",
part: {
type: "tool",
callID: "call_failed_tool",
tool: "shell",
state: {
status: "error",
input: { command: "printf partial && false" },
error: "tool failed",
},
},
})
expect(events[0].part.state.output).toBeUndefined()
expect(events[0].part.state.metadata.structured).toBeUndefined()
expect(events[0].part.state.metadata.content).toBeUndefined()
expect(output.stderr).toBe("")
})
})
-87
View File
@@ -1,87 +0,0 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } from "@opencode-ai/client/promise"
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
function location(directory: string, workspaceID?: string): LocationGetOutput {
return { directory, workspaceID, project: { id: "project", directory } }
}
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
return {
id,
projectID: "project",
title: id,
location: { directory, workspaceID },
model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
}
const prepare = async (input: { model: ModelRef | undefined; agent: string | undefined }) => ({
model: input.model,
agent: input.agent,
})
afterEach(() => mock.restore())
describe("session target resolver", () => {
test("adopts an explicit Session location and model", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = session("ses_resume", "/session", "work_1", { providerID: "openai", id: "gpt-5" })
spyOn(client.session, "get").mockResolvedValue(selected)
spyOn(client.location, "get").mockResolvedValue(location("/session", "work_1"))
const target = await resolveSessionTarget({ client, session: selected.id, prepare })
expect(target).toMatchObject({
session: { id: "ses_resume" },
location: { directory: "/session", workspaceID: "work_1" },
model: { providerID: "openai", id: "gpt-5" },
resume: true,
})
})
test("paginates to continue the exact implicit workspace", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
const explicit = Array.from({ length: 50 }, (_, index) => session(`ses_${index}`, "/project", `work_${index}`))
const list = spyOn(client.session, "list")
.mockResolvedValueOnce({ data: explicit, cursor: { next: "page_2" } })
.mockResolvedValueOnce({ data: [session("ses_implicit", "/project")], cursor: {} })
const target = await resolveSessionTarget({ client, location: { directory: "/project" }, continue: true, prepare })
expect(list).toHaveBeenCalledTimes(2)
expect(target.session.id).toBe("ses_implicit")
})
test("prepares a fresh Session at the server Location before creation", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const order: string[] = []
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
const create = spyOn(client.session, "create").mockImplementation(async (input) => {
order.push("create")
expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } })
return session("ses_fresh", "/server", "work_1")
})
await resolveSessionTarget({
client,
agent: "requested",
prepare: async (input) => {
order.push("prepare")
expect(input.location.workspaceID).toBe("work_1")
return { model: input.model, agent: "prepared" }
},
})
expect(create).toHaveBeenCalledTimes(1)
expect(order).toEqual(["prepare", "create"])
})
test("does not retry an ambiguous Session creation", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
spyOn(client.session, "create").mockRejectedValue(new Error("connection closed after create"))
await expect(resolveSessionTarget({ client, prepare })).rejects.toBeInstanceOf(SessionTargetMutationError)
})
})
+4 -41
View File
@@ -542,50 +542,13 @@ export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.add"]>[0]
export type Endpoint11_1Input = {
readonly server: Endpoint11_1Request["params"]["server"]
readonly location?: Endpoint11_1Request["query"]["location"]
readonly config: Endpoint11_1Request["payload"]["config"]
}
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.add"]>>
export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
type Endpoint11_2Request = Parameters<RawClient["server.mcp"]["mcp.remove"]>[0]
export type Endpoint11_2Input = {
readonly server: Endpoint11_2Request["params"]["server"]
readonly location?: Endpoint11_2Request["query"]["location"]
}
export type Endpoint11_2Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.remove"]>>
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
type Endpoint11_3Request = Parameters<RawClient["server.mcp"]["mcp.connect"]>[0]
export type Endpoint11_3Input = {
readonly server: Endpoint11_3Request["params"]["server"]
readonly location?: Endpoint11_3Request["query"]["location"]
}
export type Endpoint11_3Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.connect"]>>
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
type Endpoint11_4Request = Parameters<RawClient["server.mcp"]["mcp.disconnect"]>[0]
export type Endpoint11_4Input = {
readonly server: Endpoint11_4Request["params"]["server"]
readonly location?: Endpoint11_4Request["query"]["location"]
}
export type Endpoint11_4Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.disconnect"]>>
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
type Endpoint11_5Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint11_5Input = { readonly location?: Endpoint11_5Request["query"]["location"] }
export type Endpoint11_5Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
export interface McpApi<E = never> {
readonly list: McpListOperation<E>
readonly add: McpAddOperation<E>
readonly remove: McpRemoveOperation<E>
readonly connect: McpConnectOperation<E>
readonly disconnect: McpDisconnectOperation<E>
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
}
+4 -51
View File
@@ -650,61 +650,14 @@ type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["loc
const Endpoint11_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_0Input) =>
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.add"]>[0]
type Endpoint11_1Input = {
readonly server: Endpoint11_1Request["params"]["server"]
readonly location?: Endpoint11_1Request["query"]["location"]
readonly config: Endpoint11_1Request["payload"]["config"]
}
const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_1Input) =>
raw["mcp.add"]({
params: { server: input["server"] },
query: { location: input["location"] },
payload: { config: input["config"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint11_2Request = Parameters<RawClient["server.mcp"]["mcp.remove"]>[0]
type Endpoint11_2Input = {
readonly server: Endpoint11_2Request["params"]["server"]
readonly location?: Endpoint11_2Request["query"]["location"]
}
const Endpoint11_2 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_2Input) =>
raw["mcp.remove"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint11_3Request = Parameters<RawClient["server.mcp"]["mcp.connect"]>[0]
type Endpoint11_3Input = {
readonly server: Endpoint11_3Request["params"]["server"]
readonly location?: Endpoint11_3Request["query"]["location"]
}
const Endpoint11_3 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_3Input) =>
raw["mcp.connect"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint11_4Request = Parameters<RawClient["server.mcp"]["mcp.disconnect"]>[0]
type Endpoint11_4Input = {
readonly server: Endpoint11_4Request["params"]["server"]
readonly location?: Endpoint11_4Request["query"]["location"]
}
const Endpoint11_4 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_4Input) =>
raw["mcp.disconnect"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint11_5Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
type Endpoint11_5Input = { readonly location?: Endpoint11_5Request["query"]["location"] }
const Endpoint11_5 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_5Input) =>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_1Input) =>
raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.mcp"]) => ({
list: Endpoint11_0(raw),
add: Endpoint11_1(raw),
remove: Endpoint11_2(raw),
connect: Endpoint11_3(raw),
disconnect: Endpoint11_4(raw),
resource: { catalog: Endpoint11_5(raw) },
resource: { catalog: Endpoint11_1(raw) },
})
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
@@ -104,14 +104,6 @@ import type {
IntegrationCommandCancelOutput,
McpListInput,
McpListOutput,
McpAddInput,
McpAddOutput,
McpRemoveInput,
McpRemoveOutput,
McpConnectInput,
McpConnectOutput,
McpDisconnectInput,
McpDisconnectOutput,
McpResourceCatalogInput,
McpResourceCatalogOutput,
CredentialUpdateInput,
@@ -1052,55 +1044,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
add: (input: McpAddInput, requestOptions?: RequestOptions) =>
request<McpAddOutput>(
{
method: "PUT",
path: `/api/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
body: { config: input["config"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: McpRemoveInput, requestOptions?: RequestOptions) =>
request<McpRemoveOutput>(
{
method: "DELETE",
path: `/api/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
connect: (input: McpConnectInput, requestOptions?: RequestOptions) =>
request<McpConnectOutput>(
{
method: "POST",
path: `/api/mcp/${encodeURIComponent(input.server)}/connect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
disconnect: (input: McpDisconnectInput, requestOptions?: RequestOptions) =>
request<McpDisconnectOutput>(
{
method: "POST",
path: `/api/mcp/${encodeURIComponent(input.server)}/disconnect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
resource: {
catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
request<McpResourceCatalogOutput>(
@@ -2482,14 +2482,6 @@ export type ProviderNotFoundError = {
export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError"
export type McpServerNotFoundError = {
readonly _tag: "McpServerNotFoundError"
readonly server: string
readonly message: string
}
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
@@ -3469,84 +3461,6 @@ export type McpListOutput = {
data: Array<McpServer>
}
export type McpAddInput = {
readonly server: { readonly server: string }["server"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly config: {
readonly config:
| {
readonly type: "local"
readonly command: ReadonlyArray<string>
readonly cwd?: string | undefined
readonly environment?: { readonly [x: string]: string } | undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
}
| {
readonly type: "remote"
readonly url: string
readonly headers?: { readonly [x: string]: string } | undefined
readonly oauth?:
| {
readonly client_id?: string | undefined
readonly client_secret?: string | undefined
readonly scope?: string | undefined
readonly callback_port?: number | undefined
readonly redirect_uri?: string | undefined
}
| false
| undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
}
}["config"]
}
export type McpAddOutput = void
export type McpRemoveInput = {
readonly server: { readonly server: string }["server"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpRemoveOutput = void
export type McpConnectInput = {
readonly server: { readonly server: string }["server"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpConnectOutput = void
export type McpDisconnectInput = {
readonly server: { readonly server: string }["server"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpDisconnectOutput = void
export type McpResourceCatalogInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+136 -97
View File
@@ -1,30 +1,41 @@
# @opencode-ai/codemode
This is our take on code mode: a lightweight, pure interpreter for a JavaScript-like language built around calling
tools. It supports familiar JavaScript syntax with a few key differences and limitations. See the
[interpreter support checklist](./interpreter-support.md) for more details.
This is our take on code mode. Programs are written in a lightweight, JavaScript-like DSL and run in the package's
own interpreter. They never execute as actual JavaScript, so there is no runtime to escape into. The interpreter
itself can reach nothing; every effect a program has goes through a tool you explicitly supplied. The tradeoff is a
bounded language rather than full JavaScript: the [interpreter support checklist](./interpreter-support.md) documents
exactly what is supported.
Rather than trying to sandbox arbitrary JavaScript, CodeMode only runs the language features we implement. Programs
cannot directly access the network, filesystem, processes, or application APIs. They can interact with the outside
world only through tools provided by the host, which can also limit execution time, tool calls, output size, and data.
The idea of code mode was originally introduced by Cloudflare. See
[their post](https://blog.cloudflare.com/code-mode/) to learn more about the concept and their isolate-based approach.
[Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes
generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application
runs, no sandbox required.
## How it differs from JavaScript
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
becomes `null`.
The deliberate differences:
Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location. Current gaps are tracked in the
- **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard
library and supplied `tools`.
- **No dynamic code.** No `eval`, `Function`, or module loading.
- **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp,
Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary.
- **Eager, supervised promises.** Tool calls and async functions start immediately when called. Whatever is still
running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must
await every call whose completion matters. Rejections that settle un-awaited become `warnings` on the result instead
of crashing the run.
- **REPL-style results.** An omitted `return` yields the final top-level expression; `undefined` normalizes to `null`.
Beyond these, the language is a growing subset rather than a divergent one: unsupported syntax returns an
`UnsupportedSyntax` diagnostic with a source location, and current gaps (for example thenable assimilation, classes,
generators, and full sparse-array parity) are tracked as unchecked items in the
[interpreter support checklist](./interpreter-support.md).
## Quick Start
The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect`
and should depend on `effect` themselves. Define tools with Effect Schema, then expose them to programs through
`tools`:
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
@@ -37,133 +48,161 @@ const lookupOrder = Tool.make({
})
const runtime = CodeMode.make({
tools: { orders: { lookup: lookupOrder } },
tools: {
orders: {
lookup: lookupOrder,
},
},
})
const result = await Effect.runPromise(
const result =
yield *
runtime.execute(`
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
`),
)
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
`)
```
`result` is always a [`CodeMode.Result`](#results).
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics
rather than failing the Effect; host interruption remains interruption.
## API
### `Tool.make`
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only
shape the model-visible signature. Without `output`, the signature uses `Promise<unknown>`.
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input
is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON
Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise<unknown>`.
Descriptions and schemas are model-visible contract; keep authorization in `run`.
Descriptions and schemas are model-visible contracts. Authorization belongs in `run`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as
Dots in tool names are namespace separators: `{ "issues.list": tool }` exposes `tools.issues.list(...)`, exactly like
`{ issues: { list: tool } }`. Other non-identifier characters render with bracket notation, e.g.
`tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make`
`CodeMode.execute({ ...options, code })` runs once. `CodeMode.make(options)` creates a reusable runtime:
`CodeMode.execute({ ...options, code })` runs once and is equivalent to `CodeMode.make(options).execute(code)`. A
runtime from `make` reuses the tool set and policy:
```ts
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
runtime.execute(source) // CodeMode.Result
```
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional
`onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are
Effect-returning and must not fail.
### OpenAPI tools
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
create namespaces:
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into namespaced tools - one tool per operation, using dotted
`operationId` segments as namespaces:
```ts
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
```
The synchronous result is `{ tools, skipped }`. Operations with unsupported parameter encodings, request bodies
without JSON content, WebSocket or SSE semantics, or binary responses are reported in `skipped`.
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
model-visible; generated tools require `HttpClient.HttpClient` in the environment. `readOnly` properties are omitted
from request signatures and `writeOnly` properties from response signatures. These JSON Schemas are model-facing, not
runtime filters: nested value bodies and server responses pass through unchanged. See the option docstrings in
`src/openapi/types.ts` for full semantics.
Authentication is resolved by the host and never shown to the model. Generated tools require `HttpClient.HttpClient`.
Request signatures omit `readOnly` properties; response signatures omit `writeOnly` properties. These JSON Schemas
shape model-visible signatures but do not filter runtime values: nested JSON body properties and decoded server
responses pass through unchanged. See `src/openapi/types.ts` for option details.
## Outputs
## Results
Every execution returns:
Every execution returns a `CodeMode.Result`:
```ts
type Result =
| {
readonly ok: true
readonly value: CodeMode.DataValue
readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
| {
readonly ok: false
readonly error: CodeMode.Diagnostic
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
type Result = Success | Failure
interface Success {
readonly ok: true
readonly value: CodeMode.DataValue
readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
interface Failure {
readonly ok: false
readonly error: CodeMode.Diagnostic
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
```
`value` is JSON-safe. `warnings` are non-fatal diagnostics, `logs` contain program console output, and `truncated`
indicates that retained output was cut by `maxOutputBytes`. `toolCalls` retains admitted calls in order, including after
failure.
`value` is JSON-safe data. `warnings` are non-fatal diagnostics alongside a valid value (un-awaited rejections,
timeout cleanup after the return). `logs` holds program console output, `truncated` marks any output-budget cut, and
`toolCalls` lists admitted calls in order - retained on failure for auditing.
Diagnostic kinds:
Failure `error` and success `warnings` share one diagnostic vocabulary:
| Kind | Meaning |
| ----------------------- | ---------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | The program referenced an unavailable tool. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract. |
| `ToolCallLimitExceeded` | The program exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution timed out; as a warning, background work was interrupted after the program returned. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning only: additional warnings were omitted by `maxOutputBytes`. |
| Kind | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`; as a warning, background work was interrupted after the program returned. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` explicitly exposes a
safe refusal to the model; its optional cause remains private.
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` is the explicit channel
for a model-visible refusal; its optional cause never crosses the boundary.
## Discovery
Generated instructions contain a tool catalog with a default budget of 2,000 estimated tokens. Configure it with
`discovery: { catalogBudget }`. Every namespace remains visible, and the instructions say whether the catalog is
complete or partial.
The synchronous `search(...)` built-in is always available and advertised when the catalog is partial. It supports
exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full
signatures. Search counts toward `maxToolCalls`.
The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with
`discovery: { catalogBudget }`): every namespace is always listed with its tool count, signatures are selected
round-robin so every namespace gets representation, and the instructions state whether the list is complete or
partial. Programs also get a global `search(...)` built-in - always available, advertised when the list is partial:
synchronous, deterministic field-weighted substring matching that returns directly callable paths with full
signatures, supports namespace scoping and pagination, and treats an empty query as browsing and an exact path as
lookup. Search counts as an admitted tool call.
## Execution Limits
| Limit | Default | Controls |
| ---------------- | --------- | ------------------------------- |
| `timeoutMs` | unlimited | Total execution time. |
| `maxToolCalls` | unlimited | Admitted tool calls. |
| `maxOutputBytes` | unlimited | Retained result value and logs. |
| Limit | Default | Bounds |
| ---------------- | -------------------: | ---------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
Execution limits have no default values.
No limit has a default, on purpose: execution budgets are host policy. A host without its own truncation or
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. CodeMode does not limit
tool-call concurrency. Data nesting at boundaries is limited to 32 levels.
Invalid limit configuration throws `RangeError`. Warnings receive a separate budget equal to `maxOutputBytes`.
Truncation does not fail execution; an oversized value becomes a string with an in-band marker. Timeouts interrupt
tool calls and busy loops, while a result returned before cleanup times out remains successful with a
`TimeoutExceeded` warning. Tool-call concurrency is unrestricted. Boundary data is limited to 32 nested levels.
## Boundaries and Non-Goals
The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy.
CodeMode owns interpretation, schema and plain-data boundaries, resource limits, diagnostics, and discovery. A program
can only exercise authority already present in the supplied tools - do not expose a broad tool and expect the prompt
to restrict it.
Non-goals: permission prompts and approval workflows, durable pause/resume or replay, exactly-once side effects,
application authorization policy, sandboxing arbitrary JavaScript, and compatibility with the full language or npm
ecosystem. Applications that need approval or durable consequences should model those above CodeMode and expose only
the currently authorized tools.
## Testing
From the package directory:
```sh
bun test
bun run typecheck
```
+12 -11
View File
@@ -91,8 +91,8 @@ ultimate source of truth.
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
- [x] Promise-returning string replacers are coerced synchronously to `"[object Promise]"`, like JavaScript; they are
not automatically awaited.
- [ ] Stop automatically awaiting promise-returning string replacers; match JavaScript's synchronous callback-result
coercion.
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so
ignoring it matches JS arrow-function semantics exactly.
- [ ] `this` in non-arrow CodeMode functions and callbacks.
@@ -196,11 +196,11 @@ ultimate source of truth.
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
- [x] Canonical array/string index parsing: keys such as `"01"` remain non-index properties rather than aliasing index
`1`; arbitrary array-property assignment remains unsupported.
- [x] `Array.prototype.sort` preserves trailing holes, while `toSorted` densifies holes into `undefined` elements,
like JavaScript.
- [ ] `Array.prototype.toSpliced`.
- [ ] Canonical array/string index parsing: a key such as `"01"` must remain an ordinary property key rather than
aliasing index `1`.
- [ ] `Array.prototype.sort` and `toSorted` must preserve trailing holes; they currently turn holes into own
`undefined` elements.
## Strings
@@ -266,10 +266,11 @@ ultimate source of truth.
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
- [ ] Date setters.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [ ] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [ ] Native one-argument Date coercion; unsupported boolean/object inputs currently become invalid dates instead of
being coerced.
- [ ] Native Date loose-equality and default primitive-coercion semantics.
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
- [ ] Native `RangeError` branding for invalid `toISOString()` calls.
## Regular expressions
@@ -278,7 +279,7 @@ ultimate source of truth.
- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`.
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`.
- [ ] Writable `lastIndex`.
- [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags.
- [ ] `RegExp.escape`.
+9 -29
View File
@@ -428,14 +428,16 @@ const invokeStringReplacer = <R>(
let end = 0
for (const match of matches) {
const replacement = yield* apply(match.args)
const resolved =
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise
? yield* runner.settlePromise(replacement)
: replacement
// Error values are branded plain objects; boundedData would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
replacement instanceof CodeModePromise
? "[object Promise]"
: errorBrandName(replacement)
? coerceToString(replacement)
: coerceToString(boundedData(replacement, `String.${name} replacer result`)),
errorBrandName(resolved)
? coerceToString(resolved)
: coerceToString(boundedData(resolved, `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
@@ -662,20 +664,11 @@ const invokeArrayMethod = <R>(
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
case "reverse":
return Effect.succeed(target.reverse())
case "sort": {
const length = target.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(target, index)).filter((own) => !own).length
const itemCount = length - holeCount
case "sort":
return Effect.map(sortArray(runner, target, args[0], "Array.sort", node), (sorted) => {
sorted.slice(0, itemCount).forEach((item, index) => {
target[index] = item
})
Array.from({ length: holeCount }, (_, index) => itemCount + index).forEach((index) => {
Reflect.deleteProperty(target, index)
})
target.splice(0, target.length, ...sorted)
return target
})
}
case "toSorted":
return sortArray(runner, target, args[0], "Array.toSorted", node)
case "toReversed":
@@ -714,19 +707,6 @@ const invokeArrayMethod = <R>(
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
}
case "toSpliced": {
if (args.length === 0) return Effect.succeed([...target])
const start = optNumber(args[0], "start") ?? 0
if (args.length === 1) {
const copied = [...target]
copied.splice(start)
return Effect.succeed(copied)
}
const deleteCount = optNumber(args[1], "delete count") ?? 0
const copied = [...target]
copied.splice(start, deleteCount, ...args.slice(2))
return Effect.succeed(copied)
}
case "fill": {
rejectCircularInsertion(target, args[0], "Array.fill result", node)
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SafeObject } from "../tool-runtime.js"
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
import type { CodeModePromise, CodeModeURL } from "../values.js"
export type SourcePosition = {
line: number
@@ -35,7 +35,7 @@ export type StatementResult =
| { kind: "continue" }
export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
target: SafeObject | Array<unknown> | CodeModeURL
key: string | number
}
+31 -67
View File
@@ -96,15 +96,6 @@ const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
URL: urlStatics,
}
const MAX_ARRAY_LENGTH = 4_294_967_295
const parseArrayIndex = (key: string | number): number | undefined => {
const property = String(key)
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
const index = Number(property)
return index < MAX_ARRAY_LENGTH ? index : undefined
}
const calleeDescription = (callee: AstNode): string => {
if (callee.type === "Identifier") return getString(callee, "name")
if (callee.type === "MemberExpression") {
@@ -1104,7 +1095,7 @@ export class Interpreter<R> {
const args = yield* self.evaluateCallArguments(argNodes)
switch (name) {
case "Date":
return yield* self.constructDate(args, node)
return self.constructDate(args)
case "RegExp":
return self.constructRegExp(args, node)
case "Map":
@@ -1142,37 +1133,17 @@ export class Interpreter<R> {
)
}
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> {
if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now()))
private constructDate(args: Array<unknown>): CodeModeDate {
if (args.length === 0) return new CodeModeDate(Date.now())
if (args.length === 1) {
const arg = args[0]
if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time))
return Effect.map(this.toDatePrimitive(arg, node), (value) =>
typeof value === "string"
? new CodeModeDate(Date.parse(value))
: new CodeModeDate(new Date(coerceToNumber(value)).getTime()),
)
if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time)
if (typeof arg === "number") return new CodeModeDate(new Date(arg).getTime())
if (typeof arg === "string") return new CodeModeDate(Date.parse(arg))
return new CodeModeDate(Number.NaN)
}
const parts = args.map((arg) => coerceToNumber(arg))
return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime()))
}
private toDatePrimitive(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
if (value === null || (typeof value !== "object" && typeof value !== "function")) return Effect.succeed(value)
const object = value as Record<string, unknown>
const self = this
return Effect.gen(function* () {
if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
const result = yield* self.runner.invokeCallable(object.valueOf, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
if (!Object.hasOwn(object, "toString")) return coerceToString(value)
if (typeofValue(object.toString) === "function") {
const result = yield* self.runner.invokeCallable(object.toString, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
})
return new CodeModeDate(new Date(...(parts as [number, number])).getTime())
}
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
@@ -1925,8 +1896,8 @@ export class Interpreter<R> {
if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length)
const index = parseArrayIndex(key)
if (index !== undefined) return new ComputedValue(objectValue[index])
if (typeof key === "number") return new ComputedValue(objectValue[key])
if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)])
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
@@ -1958,7 +1929,6 @@ export class Interpreter<R> {
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeRegExp) {
if (key === "lastIndex") return { target: objectValue, key }
if (typeof key === "string" && regexpProperties.has(key)) {
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
}
@@ -2021,14 +1991,18 @@ export class Interpreter<R> {
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (
key !== "length" &&
!(typeof key === "string" && arrayMethods.has(key)) &&
typeof key !== "number" &&
!/^\d+$/.test(key)
) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
}
return new ComputedValue(undefined)
}
return { target: objectValue, key: index ?? key }
return { target: objectValue, key }
}
return { target: objectValue as SafeObject, key }
@@ -2049,11 +2023,11 @@ export class Interpreter<R> {
)
return reference
if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return reference.target[reference.key]
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
return new IntrinsicReference(reference.target, reference.key)
}
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
}
@@ -2084,9 +2058,6 @@ export class Interpreter<R> {
) {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
}
if (reference.target instanceof CodeModeRegExp) {
return Reflect.deleteProperty(reference.target.regex, reference.key)
}
return Reflect.deleteProperty(reference.target, reference.key)
})
}
@@ -2118,33 +2089,30 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
}
}
const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
const current =
reference.target instanceof CodeModeURL
? (reference.target.url as unknown as Record<string, unknown>)[key]
: (reference.target as Record<PropertyKey, unknown>)[key]
const { write, next, result } = yield* compute(current)
if (write) self.assignToReference(reference, key, next, node)
return result
})
}
private readReferenceValue(reference: MemberReference, key: number | string): unknown {
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[key]
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return (reference.target as Record<PropertyKey, unknown>)[key]
}
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) {
const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
const index = key as number
if (!Number.isInteger(index) || index < 0) {
throw new InterpreterRuntimeError(
"Array assignment index must be a valid array index.",
"Array assignment index must be a non-negative integer.",
node,
"InvalidDataValue",
)
}
rejectCircularInsertion(target, next, "Array assignment result", node)
target[key] = next
target[index] = next
return
}
if (reference.target instanceof CodeModeURL) {
@@ -2161,10 +2129,6 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
}
}
if (reference.target instanceof CodeModeRegExp) {
reference.target.lastIndex = next
return
}
const target = reference.target as SafeObject
const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node)
@@ -29,7 +29,6 @@ export const arrayMethods = new Set([
"shift",
"unshift",
"splice",
"toSpliced",
"fill",
"copyWithin",
"keys",
+1 -6
View File
@@ -4,8 +4,6 @@ export const dateMethods = new Set([
"toISOString",
"toJSON",
"toString",
"toUTCString",
"toGMTString",
"getFullYear",
"getMonth",
"getDate",
@@ -47,15 +45,12 @@ export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNod
case "valueOf":
return value.time
case "toISOString":
if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node).as("RangeError")
if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node)
return hosted.toISOString()
case "toJSON":
return Number.isFinite(value.time) ? hosted.toISOString() : null
case "toString":
return coerceToString(value)
case "toUTCString":
case "toGMTString":
return hosted.toUTCString()
case "getFullYear":
return hosted.getFullYear()
case "getMonth":
+3 -18
View File
@@ -59,18 +59,9 @@ export const invokeRegExpMethod = (
): unknown => {
switch (name) {
case "test":
return value.regex.test(coerceToString(args[0]))
case "exec": {
const input = coerceToString(args[0])
const lastIndex = value.lastIndex
const stateful = value.regex.global || value.regex.sticky
value.regex.lastIndex = toLength(lastIndex)
if (name === "test") {
const matched = value.regex.test(input)
if (!stateful) value.lastIndex = lastIndex
return matched
}
const matched = value.regex.exec(input)
if (!stateful) value.lastIndex = lastIndex
const matched = value.regex.exec(coerceToString(args[0]))
return matched === null ? null : matchToValue(matched)
}
case "toString":
@@ -79,13 +70,7 @@ export const invokeRegExpMethod = (
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
}
}
const toLength = (value: unknown): number => {
const number = coerceToNumber(value)
if (Number.isNaN(number) || number <= 0) return 0
return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { CodeModeRegExp } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
import { coerceToString } from "./value.js"
-8
View File
@@ -13,14 +13,6 @@ export class CodeModeRegExp {
constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags)
}
get lastIndex(): unknown {
return Reflect.get(this.regex, "lastIndex")
}
set lastIndex(value: unknown) {
Reflect.set(this.regex, "lastIndex", value)
}
}
export class CodeModeMap {
@@ -29,11 +29,6 @@
* - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.1_T1.js
* - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.2_T1.js
* - test/built-ins/Array/prototype/splice/called_with_one_argument.js
* - test/built-ins/Array/prototype/toSpliced/holes-not-preserved.js
* - test/built-ins/Array/prototype/toSpliced/immutable.js
* - test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-undefineds.js
* - test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-missing.js
* - test/built-ins/Array/prototype/toSpliced/start-undefined-and-deleteCount-missing.js
* - test/built-ins/Array/prototype/fill/fill-values.js
* - test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js
* - test/built-ins/Array/prototype/fill/return-this.js
@@ -64,8 +59,6 @@
* Copyright 2015 Microsoft Corporation. All rights reserved.
* Copyright 2016 The V8 project authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
* The toSpliced hole case omits the source test's inherited Array.prototype element because CodeMode does not expose
* prototype mutation; it retains the source test's hole-densification assertions.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
@@ -234,31 +227,6 @@ const cases = [
code: `const input = ["first", "second", "third"]; const removed = input.splice(1); return [input, removed]`,
expected: [["first"], ["second", "third"]],
},
{
path: "test/built-ins/Array/prototype/toSpliced/immutable.js",
code: `const input = [2, 0, 1]; const inserted = input.toSpliced(0, 0, -1); const replaced = input.toSpliced(0, 1, -1); return [input, inserted, replaced, inserted !== input, replaced !== input]`,
expected: [[2, 0, 1], [-1, 2, 0, 1], [-1, 0, 1], true, true],
},
{
path: "test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-missing.js",
code: `const input = ["first", "second", "third"]; const result = input.toSpliced(); return [result, result !== input]`,
expected: [["first", "second", "third"], true],
},
{
path: "test/built-ins/Array/prototype/toSpliced/start-undefined-and-deleteCount-missing.js",
code: `return ["first", "second", "third"].toSpliced(undefined)`,
expected: [],
},
{
path: "test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-undefineds.js",
code: `const input = ["first", "second", "third"]; const result = input.toSpliced(undefined, undefined); return [result, result !== input]`,
expected: [["first", "second", "third"], true],
},
{
path: "test/built-ins/Array/prototype/toSpliced/holes-not-preserved.js",
code: `const input = [0, , 2, , 4]; const result = input.toSpliced(0, 0, -1); return [result, 2 in result, 4 in result]`,
expected: [[-1, 0, null, 2, null, 4], true, true],
},
{
path: "test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js",
code: `const input = [0, 0, 0, 0, 0]; input.fill(8, -3, 4); const sparse = []; sparse[4] = 0; sparse.fill(8, 1, 3); return [[0, 0, 0].fill(8, 1, 2), input, [0, 0, 0, 0, 0].fill(8, -2, -1), [0, 0, 0, 0, 0].fill(8, -1, -3), [0 in sparse, sparse[1], sparse[2], 3 in sparse, sparse[4]]]`,
+4 -51
View File
@@ -131,6 +131,10 @@ describe("constructors callable without new, like JS", () => {
expect((await error(`try { Array(-1) } catch (e) { throw Error(e.name) }`)).message).toContain("RangeError")
})
test("sort densifies trailing holes into undefined (documented divergence)", async () => {
expect(await value(`return Array(2).sort().map(() => 1)`)).toEqual([1, 1])
})
test("returned sparse arrays normalize holes to null at the host boundary", async () => {
expect(await value(`return Array(3)`)).toEqual([null, null, null])
})
@@ -149,57 +153,6 @@ describe("constructors callable without new, like JS", () => {
})
describe("sort accepts the unified callback set", () => {
test("sort preserves trailing holes while toSorted densifies them", async () => {
expect(
await value(`
const defaultSorted = [2, , 1]
const compared = [2, , 1]
const copied = defaultSorted.toSorted()
defaultSorted.sort()
compared.sort((a, b) => a - b)
return [
Object.hasOwn(defaultSorted, 2),
Object.hasOwn(compared, 2),
Object.hasOwn(copied, 2),
]
`),
).toEqual([false, false, true])
expect(await value(`const values = [2, undefined, 1]; values.sort(); return Object.hasOwn(values, 2)`)).toBe(true)
})
test("sort writes its snapshot without discarding comparator length mutations", async () => {
expect(
await value(`
const values = [3, 2, 1]
let first = true
values.sort((a, b) => {
if (first) {
first = false
values.push("kept")
}
return a - b
})
return values
`),
).toEqual([1, 2, 3, "kept"])
expect(
await value(`
const values = [3, , 1, , 2]
let first = true
values.sort((a, b) => {
if (first) {
first = false
values.splice(0)
}
return a - b
})
return { values, owns: values.map((_, index) => Object.hasOwn(values, index)) }
`),
).toEqual({ values: [1, 2, 3], owns: [true, true, true] })
})
test("sort and toSorted take built-in comparators", async () => {
expect(await value(`return [0, 1, 0].sort(Boolean)`)).toEqual([0, 0, 1])
expect(await value(`return [0, 1, 0].toSorted(Boolean)`)).toEqual([0, 0, 1])
+1 -42
View File
@@ -42,15 +42,6 @@ describe("H2: string property access reads as undefined (not a throw)", () => {
test("unknown property on a number is undefined", async () => {
expect(await value(`return (5).foo ?? "n"`)).toBe("n")
})
test("only canonical string index keys access characters", async () => {
expect(
await value(`
const text = "abc"
return [text[1], text["1"], text["01"], text["1.0"], text[-0], text["-0"]]
`),
).toEqual(["b", "b", null, null, "a", null])
})
})
describe("H3: array property access reads as undefined (not a throw)", () => {
@@ -64,45 +55,13 @@ describe("H3: array property access reads as undefined (not a throw)", () => {
})
test("unknown property reads stay undefined for methods CodeMode does not implement", async () => {
expect(await value(`return [1,2,3].unknownMethod === undefined`)).toBe(true)
expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true)
})
test("array indexing still works", async () => {
expect(await value(`return [1,2,3][9] === undefined`)).toBe(true)
expect(await value(`return [1,2,3][9]`)).toBeNull()
})
test("only canonical array index keys access elements", async () => {
expect(
await value(`
const values = ["a", "b"]
return [values[1], values["1"], values["01"], values["1.0"], values[-0], values["-0"]]
`),
).toEqual(["b", "b", null, null, "a", null])
})
test("noncanonical keys cannot mutate or delete an aliased element", async () => {
expect(
await value(`
const values = ["a", "b"]
let writes = 0
try { values["01"] = ++writes } catch {}
const removed = delete values["01"]
return [writes, removed, values]
`),
).toEqual([0, true, ["a", "b"]])
})
test("the maximum array length is not accepted as an array index", async () => {
expect(
await value(`
const values = []
let writes = 0
try { values["4294967295"] = ++writes } catch {}
return [writes, values.length]
`),
).toEqual([0, 0])
})
})
describe("H6: object spread of null/undefined is a no-op", () => {
+7 -132
View File
@@ -1,19 +1,3 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/built-ins/Date/value-to-primitive-result-non-string-prim.js
* - test/built-ins/Date/value-to-primitive-result-string.js
* - test/built-ins/Date/prototype/toUTCString/format.js
* - test/built-ins/Date/prototype/toUTCString/invalid-date.js
* - test/built-ins/RegExp/prototype/exec/S15.10.6.2_A4_T8.js
*
* CodeMode does not support Symbol.toPrimitive, so the Date-constructor cases exercise the same primitive-result
* handling through supported own valueOf and toString functions.
*
* Copyright (C) 2016 the V8 project authors. All rights reserved.
* Copyright (C) 2017 the V8 project authors. All rights reserved.
* Copyright 2009 the Sputnik authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
@@ -71,52 +55,6 @@ describe("Date", () => {
expect(await value(`return Date.parse("2024-01-02T03:04:05.000Z")`)).toBe(1704164645000)
})
test("one-argument construction coerces supported values like JavaScript", async () => {
expect(
await value(`return [new Date(true).getTime(), new Date(false).getTime(), new Date(null).getTime()]`),
).toEqual([1, 0, 0])
expect(await value(`return Number.isNaN(new Date(undefined).getTime())`)).toBe(true)
expect(await value(`return Number.isNaN(new Date([]).getTime())`)).toBe(true)
expect(await value(`return new Date(["1970-01-01T00:00:00.000Z"]).getTime()`)).toBe(0)
expect(await value(`return Number.isNaN(new Date({}).getTime())`)).toBe(true)
})
test("one-argument construction uses valueOf then toString for objects", async () => {
expect(
await value(`
const calls = []
const number = { valueOf: () => 8 }
const text = {
valueOf: () => { calls.push("valueOf"); return {} },
toString: () => { calls.push("toString"); return "2016-06-05T18:40:00.000Z" },
}
return [new Date(number).getTime(), new Date(text).getTime(), calls]
`),
).toEqual([8, 1465152000000, ["valueOf", "toString"]])
expect(
await value(`
const values = [
{ valueOf: () => undefined },
{ valueOf: () => true },
{ valueOf: () => false },
{ valueOf: () => null },
]
return values.map((item) => new Date(item).getTime())
`),
).toEqual([null, 1, 0, 0])
expect(
await value(`
try {
new Date({ valueOf: () => ({}), toString: () => ({}) })
} catch (error) {
return error.name
}
`),
).toBe("TypeError")
})
test("date arithmetic and comparison use the time value", async () => {
expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000)
expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true)
@@ -136,9 +74,9 @@ describe("Date", () => {
expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
})
test("toISOString on an invalid date throws RangeError", async () => {
expect(await value(`try { new Date("garbage").toISOString() } catch (error) { return error.name }`)).toBe(
"RangeError",
test("toISOString on an invalid date is a catchable error", async () => {
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe(
"caught",
)
})
@@ -180,25 +118,6 @@ describe("Date", () => {
expect(await value(`return typeof new Date(0)`)).toBe("object")
expect(await value(`return new Date(0).nope === undefined`)).toBe(true)
})
test("toUTCString and toGMTString use the native UTC format", async () => {
expect(
await value(`
const date = new Date(0)
return [
date.toUTCString(),
date.toGMTString(),
new Date(NaN).toUTCString(),
new Date("0020-01-01T00:00:00Z").toUTCString(),
]
`),
).toEqual([
"Thu, 01 Jan 1970 00:00:00 GMT",
"Thu, 01 Jan 1970 00:00:00 GMT",
"Invalid Date",
"Wed, 01 Jan 0020 00:00:00 GMT",
])
})
})
describe("RegExp", () => {
@@ -235,51 +154,6 @@ describe("RegExp", () => {
).toEqual(["1", "22"])
})
test("lastIndex is writable and exec coerces its stored value", async () => {
expect(
await value(`
const pattern = /(?:ab|cd)\\d?/g
pattern.lastIndex = "12"
const stored = [pattern.lastIndex, typeof pattern.lastIndex]
const match = pattern.exec("aacd2233ab12nm444ab42")
pattern.lastIndex = 0
return [stored, match[0], match.index, pattern.lastIndex, delete pattern.lastIndex]
`),
).toEqual([["12", "string"], "ab4", 17, 0, false])
})
test("exec coerces CodeMode data objects assigned to lastIndex", async () => {
expect(
await value(`
const pattern = /a/g
pattern.lastIndex = {}
const stored = pattern.lastIndex
const match = pattern.exec("ba")
pattern.lastIndex = 10
const missed = pattern.exec("a")
return [stored, match.index, pattern.lastIndex, missed]
`),
).toEqual([{}, 1, 0, null])
})
test("non-global exec and test coerce and preserve lastIndex", async () => {
expect(
await value(`
const execPattern = /a/
const execIndex = {}
execPattern.lastIndex = execIndex
const match = execPattern.exec("ba")
const testPattern = /a/
const testIndex = {}
testPattern.lastIndex = testIndex
const matched = testPattern.test("ba")
return [match.index, execPattern.lastIndex === execIndex, matched, testPattern.lastIndex === testIndex]
`),
).toEqual([1, true, true, true])
})
test("an unmatched string pattern returns null", async () => {
expect(await value(`return "abc".match(/\\d/)`)).toBeNull()
})
@@ -324,7 +198,7 @@ describe("RegExp", () => {
).toBe("7null[object Object]")
})
test("promise-returning string replacers are coerced synchronously", async () => {
test("function replacers can await effectful tool calls", async () => {
const decorate = Tool.make({
description: "Decorate a string",
input: Schema.String,
@@ -337,7 +211,7 @@ describe("RegExp", () => {
code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`,
}),
)
expect(result.ok && result.value).toBe("a[object Promise]b[object Promise]")
expect(result.ok && result.value).toBe("a[1]b[22]")
const missingAwait = await Effect.runPromise(
CodeMode.execute({
@@ -345,7 +219,8 @@ describe("RegExp", () => {
code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`,
}),
)
expect(missingAwait.ok && missingAwait.value).toBe("a[object Promise]")
expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue")
expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise")
})
test("replaceAll without the g flag is a catchable error", async () => {
+48 -12
View File
@@ -1,19 +1,55 @@
export * as ConfigMCP from "./mcp"
import { Schema } from "effect"
import { Mcp } from "@opencode-ai/schema/mcp"
import { PositiveInt } from "../schema"
// The MCP server config is a public wire contract (used by the mcp.add route), so it lives in
// @opencode-ai/schema and is re-exported here.
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
export const Local = Mcp.LocalConfig
export type Local = Mcp.LocalConfig
export const OAuth = Mcp.OAuthConfig
export type OAuth = Mcp.OAuthConfig
export const Remote = Mcp.RemoteConfig
export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: Timeout.pipe(Schema.optional),
}) {}
export class OAuth extends Schema.Class<OAuth>("ConfigV2.MCP.OAuth")({
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
}) {}
export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: Timeout.pipe(Schema.optional),
}) {}
export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type"))
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
timeout: Timeout.pipe(Schema.optional),
+5 -10
View File
@@ -365,14 +365,11 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
}),
)
yield* tools
.registerBatch(
registrations.map((registration) => ({
tools: { [registration.name]: registration.tool },
...(registration.options === undefined ? {} : { options: registration.options }),
})),
)
.pipe(Effect.orDie)
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
hook: (name, callback) => {
@@ -401,14 +398,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
input: event.input,
result: event.result,
output: event.output,
outputPaths: event.outputPaths,
}
return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.result = output.result
event.output = output.output
event.outputPaths = output.outputPaths
}),
),
)
+10 -21
View File
@@ -22,11 +22,6 @@ export interface BoundInput {
readonly output: ToolOutput
}
export interface BoundResult {
readonly output: ToolOutput
readonly outputPaths: ReadonlyArray<string>
}
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
operation: Schema.Literals(["encode", "write"]),
cause: Schema.Defect(),
@@ -41,7 +36,7 @@ export type Error = StorageError
export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
readonly bound: (input: BoundInput) => Effect.Effect<ToolOutput, Error>
readonly cleanup: () => Effect.Effect<void>
}
@@ -150,26 +145,20 @@ const layer = Layer.effect(
lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
)
return {
output: input.output,
outputPaths: [],
}
return input.output
const outputPath = yield* write(contextual)
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
output: {
structured: input.output.structured,
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
},
outputPaths: [outputPath],
structured: input.output.structured,
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
}
})
-1
View File
@@ -55,4 +55,3 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
## Current Gaps
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.

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