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", "@napi-rs/canvas": "1.0.2",
"@opencode-ai/ai": "workspace:*", "@opencode-ai/ai": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"effect": "catalog:", "effect": "catalog:",
}, },
+2 -43
View File
@@ -1,6 +1,6 @@
# @opencode-ai/ai # @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 ```ts
import { Effect } from "effect" 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. 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 ## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes. - **`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. - **`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. - **`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. - **`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 ## Caching
@@ -223,7 +182,7 @@ Adding a new model or deployment is usually 5-15 lines using `Route.make({ proto
## Effect ## 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 ## 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 { LLMClient } from "./route/client"
export { ImageClient } from "./image-client"
export { Auth } from "./route/auth" export { Auth } from "./route/auth"
export { Provider } from "./provider" export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package" export { ProviderPackage } from "./provider-package"
@@ -11,9 +10,6 @@ export type {
Service as LLMClientService, Service as LLMClientService,
} from "./route/client" } from "./route/client"
export * from "./schema" export * from "./schema"
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
export { Image } from "./image"
export { Tool, ToolFailure, toDefinitions } from "./tool" export { Tool, ToolFailure, toDefinitions } from "./tool"
export { ToolRuntime } from "./tool-runtime" export { ToolRuntime } from "./tool-runtime"
export type { DispatchResult as ToolDispatchResult, ToolSettlement } 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 BedrockConverse from "./bedrock-converse"
export * as Gemini from "./gemini" export * as Gemini from "./gemini"
export * as OpenAIChat from "./openai-chat" export * as OpenAIChat from "./openai-chat"
export * as OpenAIImages from "./openai-images"
export * as OpenAICompatibleChat from "./openai-compatible-chat" export * as OpenAICompatibleChat from "./openai-compatible-chat"
export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenAICompatibleResponses from "./openai-compatible-responses"
export * as OpenAIResponses from "./openai-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_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String), reasoning: Schema.optional(Schema.String),
reasoning_text: 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 }), Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role")) ]).pipe(Schema.toTaggedUnion("role"))
@@ -150,7 +149,6 @@ const OpenAIChatDelta = Schema.Struct({
reasoning_content: optionalNull(Schema.String), reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String), reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String), reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
}) })
@@ -166,23 +164,13 @@ export const OpenAIChatEvent = Schema.Struct({
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent> export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number] type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface PendingToolDelta {
readonly id?: string
readonly name?: string
readonly input: string
}
export interface ParserState { export interface ParserState {
readonly tools: ToolStream.State<number> readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent> readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage readonly usage?: Usage
readonly finishReason?: FinishReason readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State readonly lifecycle: Lifecycle.State
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text" 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 reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField const field = part.providerMetadata?.openai?.reasoningField
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
} return "reasoning_content"
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
} }
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { 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 text = reasoning.map((part) => part.text).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible) const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
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
})()
return { return {
role: "assistant" as const, role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content), content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls, 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: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? 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 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) => const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () { Effect.gen(function* () {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
@@ -514,56 +432,29 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const delta = choice?.delta const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? [] const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools let tools = state.tools
let pendingTools = state.pendingTools
let lifecycle = state.lifecycle let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta) const reasoning = reasoningDelta(delta)
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined) const reasoningField = state.reasoningField ?? reasoning?.field
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined if (reasoning)
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined openai: { reasoningField: reasoningField ?? reasoning.field },
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")
if (delta?.content) { if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd( lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
} }
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
for (const tool of toolDeltas) { 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( const result = ToolStream.appendOrStart(
ADAPTER, ADAPTER,
tools, tools,
tool.index, 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", "OpenAI Chat tool call delta is missing id or name",
) )
if (ToolStream.isError(result)) return yield* result if (ToolStream.isError(result)) return yield* result
@@ -572,9 +463,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
events.push(...result.events) 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 // Finalize accumulated tool inputs eagerly when finish_reason arrives so
// valid calls and malformed local calls settle independently. // valid calls and malformed local calls settle independently.
const finished = const finished =
@@ -585,15 +473,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [ return [
{ {
tools: finished?.tools ?? tools, tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents, toolCallEvents: finished?.events ?? state.toolCallEvents,
usage, usage,
finishReason, finishReason,
lifecycle, lifecycle,
reasoningField, reasoningField,
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
}, },
events, events,
] as const ] as const
@@ -603,16 +487,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0 const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
const metadata = reasoningMetadata( const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
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
events.push(...state.toolCallEvents) events.push(...state.toolCallEvents)
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events return events
@@ -637,13 +512,9 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(OpenAIChatEvent), event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({ initial: () => ({
tools: ToolStream.empty<number>(), tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [], toolCallEvents: [],
lifecycle: Lifecycle.initial(), lifecycle: Lifecycle.initial(),
reasoningField: undefined, reasoningField: undefined,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
}), }),
step, step,
onHalt: finishEvents, 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 { Route } from "../route/client"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
@@ -25,7 +25,6 @@ import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle" import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream" import { ToolStream } from "./utils/tool-stream"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-responses" const ADAPTER = "openai-responses"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
@@ -114,24 +113,11 @@ const OpenAIResponsesTool = Schema.Struct({
parameters: JsonObject, parameters: JsonObject,
strict: Schema.optional(Schema.Boolean), strict: Schema.optional(Schema.Boolean),
}) })
const OpenAIResponsesImageGenerationTool = Schema.Struct({ type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
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>
const OpenAIResponsesToolChoice = Schema.Union([ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]), Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), 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` // Fields shared between the HTTP body and the WebSocket `response.create`
@@ -142,7 +128,7 @@ const OpenAIResponsesCoreFields = {
model: Schema.String, model: Schema.String,
input: Schema.Array(OpenAIResponsesInputItem), input: Schema.Array(OpenAIResponsesInputItem),
instructions: Schema.optional(Schema.String), instructions: Schema.optional(Schema.String),
tools: optionalArray(OpenAIResponsesTools), tools: optionalArray(OpenAIResponsesTool),
tool_choice: Schema.optional(OpenAIResponsesToolChoice), tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier), service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
@@ -208,8 +194,6 @@ const OpenAIResponsesStreamItem = Schema.Struct({
outputs: Schema.optional(Schema.Unknown), outputs: Schema.optional(Schema.Unknown),
server_label: Schema.optional(Schema.String), server_label: Schema.optional(Schema.String),
output: Schema.optional(Schema.Unknown), output: Schema.optional(Schema.Unknown),
result: Schema.optional(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
error: Schema.optional(Schema.Unknown), error: Schema.optional(Schema.Unknown),
encrypted_content: optionalNull(Schema.String), encrypted_content: optionalNull(Schema.String),
}) })
@@ -274,41 +258,21 @@ const invalid = ProviderShared.invalidRequest
// ============================================================================= // =============================================================================
// Request Lowering // Request Lowering
// ============================================================================= // =============================================================================
const nativeImageToolInput = (tool: ToolDefinition) => { const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
const native = tool.native?.openai type: "function",
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined name: tool.name,
} description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
const nativeImageTool = (tool: ToolDefinition) => { // TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
const native = nativeImageToolInput(tool) strict: false,
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 lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) => const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, { ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
auto: () => "auto" as const, auto: () => "auto" as const,
none: () => "none" as const, none: () => "none" as const,
required: () => "required" as const, required: () => "required" as const,
tool: (name) => tool: (name) => ({ type: "function" as const, name }),
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
? ({ type: "image_generation" } as const)
: { type: "function" as const, name },
}) })
const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
@@ -456,13 +420,6 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const itemID = hostedToolItemID(part) const itemID = hostedToolItemID(part)
if (store !== false && itemID && !hostedToolReferences.has(itemID)) if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: 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) if (itemID) hostedToolReferences.add(itemID)
continue continue
} }
@@ -528,10 +485,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
tools: tools:
request.tools.length === 0 request.tools.length === 0
? undefined ? undefined
: yield* Effect.forEach(request.tools, (tool) => : request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), 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, stream: true as const,
max_output_tokens: generation?.maxTokens, max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature, temperature: generation?.temperature,
@@ -617,29 +574,14 @@ const isReasoningItem = (
// Round-trip the full item as the structured result so consumers can extract // Round-trip the full item as the structured result so consumers can extract
// outputs / sources / status without re-decoding. // 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 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 } 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 }, item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
) { ): ReadonlyArray<LLMEvent> => {
const tool = HOSTED_TOOLS[item.type] const tool = HOSTED_TOOLS[item.type]
const providerMetadata = openaiMetadata({ itemId: item.id }) const providerMetadata = openaiMetadata({ itemId: item.id })
return [ return [
@@ -653,12 +595,12 @@ const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function*
LLMEvent.toolResult({ LLMEvent.toolResult({
id: item.id, id: item.id,
name: tool.name, name: tool.name,
result: yield* hostedToolResult(item), result: hostedToolResult(item),
providerExecuted: true, providerExecuted: true,
providerMetadata, providerMetadata,
}), }),
] ]
}) }
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>] type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -905,7 +847,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
if (isHostedToolItem(item)) { if (isHostedToolItem(item)) {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events) const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(...(yield* hostedToolEvents(item))) events.push(...hostedToolEvents(item))
return [{ ...state, lifecycle }, events] satisfies StepResult return [{ ...state, lifecycle }, events] satisfies StepResult
} }
+1 -1
View File
@@ -44,7 +44,7 @@ export const reasoningDelta = (
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
): State => { ): State => {
const started = reasoningStart(state, events, id, providerMetadata) const started = reasoningStart(state, events, id, providerMetadata)
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata })) events.push(LLMEvent.reasoningDelta({ id, text }))
return started 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, missingToolMessage: string,
): AppendOutcome<K> | LLMError => { ): AppendOutcome<K> | LLMError => {
const current = tools[key] const current = tools[key]
const id = current?.id ?? delta.id const id = delta.id ?? current?.id
const name = current?.name ?? delta.name const name = delta.name ?? current?.name
if (!id || !name) return eventError(route, missingToolMessage) if (!id || !name) return eventError(route, missingToolMessage)
const tool = { const tool = {
+1 -11
View File
@@ -16,17 +16,13 @@ import {
const patterns = [ const patterns = [
/prompt is too long/i, /prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i, /input is too long for requested model/i,
/exceeds the context window/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, /input token count.*exceeds the maximum/i,
/tokens in request more than max tokens allowed/i, /tokens in request more than max tokens allowed/i,
/maximum prompt length is \d+/i, /maximum prompt length is \d+/i,
/reduce the length of the messages/i, /reduce the length of the messages/i,
/maximum context length is \d+ tokens/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 limit of \d+/i,
/exceeds the available context size/i, /exceeds the available context size/i,
/greater than the context length/i, /greater than the context length/i,
@@ -38,17 +34,11 @@ const patterns = [
/input length.*exceeds.*context length/i, /input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i, /prompt too long; exceeded (?:max )?context length/i,
/too large for model with \d+ maximum 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, /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) => 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) => export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError failure instanceof LLMError
+2 -57
View File
@@ -1,14 +1,12 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { Route, RouteDefaultsInput } from "../route/client" import type { Route, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package" 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 OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses" import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options" export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
export type { OpenAIImageOptions } from "../protocols/openai-images"
export const id = ProviderID.make("openai") export const id = ProviderID.make("openai")
@@ -22,44 +20,8 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string readonly baseURL?: string
readonly queryParams?: Record<string, string> readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput 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 { export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string readonly apiKey?: string
readonly baseURL?: 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 auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
const defaults = (input: Config) => { 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 return rest
} }
@@ -93,21 +55,6 @@ export const configure = (input: Config = {}) => {
const responsesWebSocket = (id: string | ModelID) => const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id }) responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).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 { return {
id, id,
@@ -115,7 +62,6 @@ export const configure = (input: Config = {}) => {
responses, responses,
responsesWebSocket, responsesWebSocket,
chat, chat,
image,
configure, configure,
} }
} }
@@ -151,4 +97,3 @@ export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID
export const responses = provider.responses export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat 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, schema: OpenRouterBody,
from: (request) => from: (request) =>
OpenAIChat.protocol.body.from(request).pipe( OpenAIChat.protocol.body.from(request).pipe(
Effect.map((body) => { Effect.map(
const sourceAssistants = request.messages.filter((message) => message.role === "assistant") (body) =>
let assistantIndex = 0 ({
const messages = body.messages.map((message) => { ...body,
if (message.role !== "assistant") return message ...bodyOptions(request.providerOptions?.openrouter),
const source = sourceAssistants[assistantIndex++] }) as OpenRouterBody,
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
}),
), ),
}, },
stream: OpenAIChat.protocol.stream, stream: OpenAIChat.protocol.stream,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect" import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http" 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 { export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError" readonly _tag = "MissingCredentialError"
@@ -15,7 +15,7 @@ export type AuthError = CredentialError | LLMError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted> type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput { export interface AuthInput {
readonly request: { readonly http?: HttpOptions } readonly request: LLMRequest
readonly method: "POST" | "GET" readonly method: "POST" | "GET"
readonly url: string readonly url: string
readonly body: 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" import { classifyProviderFailure } from "../src/provider-error"
describe("provider error classification", () => { describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => { test("classifies Z.AI GLM token limit messages as context overflow", () => {
const messages = [ expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
"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 V1 plain-text rate limit fallbacks", () => { test("classifies V1 plain-text rate limit fallbacks", () => {
+1 -54
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect" import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src" import { LLM } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare" import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { it } from "../lib/effect" 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", () => it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
@@ -1,12 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse } from "../../src" import { LLM, LLMEvent } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter" import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { recordedTests } from "../recorded-test" import { recordedTests } from "../recorded-test"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
const cases = [ const cases = [
{ {
@@ -17,7 +15,6 @@ const cases = [
}).model("anthropic/claude-sonnet-4.6"), }).model("anthropic/claude-sonnet-4.6"),
requires: ["OPENROUTER_API_KEY"], requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning", cassette: "openrouter-reasoning",
structured: true,
}, },
{ {
name: "Vercel AI Gateway", name: "Vercel AI Gateway",
@@ -29,7 +26,6 @@ const cases = [
}).model("anthropic/claude-sonnet-4.6"), }).model("anthropic/claude-sonnet-4.6"),
requires: ["AI_GATEWAY_API_KEY"], requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning", cassette: "vercel-ai-gateway-reasoning",
structured: true,
}, },
] as const ] as const
@@ -61,82 +57,11 @@ for (const item of cases) {
expect(response.text.replaceAll(",", "").trim()).toBe("37887") expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0) expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true) expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content") openai: { reasoningField: "reasoning" },
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),
}) })
}), }),
30_000, 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", () => it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( 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", () => it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( 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 { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect" import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" 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 { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" 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", () => it.effect("lowers semantic service tier options", () =>
Effect.gen(function* () { Effect.gen(function* () {
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } }) 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", () => it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>( 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", () => it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
Effect.gen(function* () { Effect.gen(function* () {
const item = { const item = {
+1 -99
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, Message } from "../../src" import { LLM } from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import * as OpenRouter from "../../src/providers/openrouter" import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect" 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") throw new Error("Weather tool loop exceeded 10 steps")
}) })
const assistantContent = (events: ReadonlyArray<LLMEvent>) => const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content 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 = ( export const expectFinish = (
events: ReadonlyArray<LLMEvent>, events: ReadonlyArray<LLMEvent>,
+2 -8
View File
@@ -3,8 +3,6 @@ import { Layer } from "effect"
import * as path from "node:path" import * as path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route" 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 LLMClientService } from "../src/route/client"
import type { Service as RequestExecutorService } from "../src/route/executor" import type { Service as RequestExecutorService } from "../src/route/executor"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
@@ -17,7 +15,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
type RecordedTestsOptions = RecordedGroupOptions & { type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions readonly options?: HttpRecorder.RecorderOptions
@@ -83,10 +81,6 @@ export const recordedTests = (options: RecordedTestsOptions) =>
), ),
) )
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer) const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll( return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
deps,
LLMClient.layer.pipe(Layer.provide(deps)),
ImageClient.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", () => it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool") 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"> <div class="flex flex-col gap-3">
<DockShellForm <DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"} data-component={newSession() ? "session-new-composer" : "session-composer"}
data-background-surface="prompt"
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true, "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 ( return (
<button <button
data-action="prompt-project" data-action="prompt-project"
data-background-surface="project-selector"
type="button" 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" 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()} onClick={() => props.controller.add()}
@@ -465,7 +464,6 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
<button <button
{...rest} {...rest}
data-action="prompt-project" data-action="prompt-project"
data-background-surface="project-selector"
type="button" 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" 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={{ classList={{
@@ -4,14 +4,10 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) { export function NewSessionDesignView(props: { children: JSX.Element }) {
return ( return (
<div <div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
data-component="session-new-design"
data-background-surface="shell"
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="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}> <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 class="mt-8">{props.children}</div>
</div> </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 { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link" import { Link } from "./link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
import { useSettingsBackgroundImage } from "./settings-background-image"
let demoSoundState = { let demoSoundState = {
cleanup: undefined as (() => void) | undefined, cleanup: undefined as (() => void) | undefined,
@@ -88,7 +87,6 @@ export const SettingsGeneral: Component = () => {
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const backgroundImage = useSettingsBackgroundImage()
const dialog = useDialog() const dialog = useDialog()
const params = useParams() const params = useParams()
const settings = useSettings() const settings = useSettings()
@@ -501,24 +499,6 @@ export const SettingsGeneral: Component = () => {
/> />
</SettingsRow> </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 <SettingsRow
title={language.t("settings.general.row.uiFont.title")} title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")} description={language.t("settings.general.row.uiFont.description")}
@@ -29,7 +29,6 @@ import { Link } from "../link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import { useSettingsBackgroundImage } from "../settings-background-image"
import "./settings-v2.css" import "./settings-v2.css"
let demoSoundState = { let demoSoundState = {
@@ -89,7 +88,6 @@ export const SettingsGeneralV2: Component<{
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const backgroundImage = useSettingsBackgroundImage()
const dialog = useDialog() const dialog = useDialog()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync() const serverSync = useServerSync()
@@ -462,29 +460,6 @@ export const SettingsGeneralV2: Component<{
/> />
</SettingsRowV2> </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 <SettingsRowV2
title={language.t("settings.general.row.uiFont.title")} title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")} description={language.t("settings.general.row.uiFont.description")}
-1
View File
@@ -227,7 +227,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return ( return (
<header <header
data-background-surface="shell"
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined} data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
classList={{ classList={{
"shrink-0 relative flex flex-row": true, "shrink-0 relative flex flex-row": true,
-12
View File
@@ -112,18 +112,6 @@ type PlatformBase = {
/** Read image from clipboard (desktop only) */ /** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null> 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) */ /** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string> exportDebugLogs?(): Promise<string>
-37
View File
@@ -1,7 +1,6 @@
// @refresh reload // @refresh reload
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { createSignal } from "solid-js"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform" 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 { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click" import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server" import { authFromToken } from "@/utils/server"
import {
clearWebBackgroundImage,
loadWebBackgroundImage,
saveWebBackgroundImage,
selectWebBackgroundImage,
} from "@/utils/web-background-image"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server" import { ServerConnection } from "./context/server"
@@ -126,21 +119,6 @@ const clearAuthToken = () => {
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash) 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 = { const platform: Platform = {
platform: "web", platform: "web",
version: pkg.version, version: pkg.version,
@@ -154,23 +132,8 @@ const platform: Platform = {
return stored ? ServerConnection.Key.make(stored) : null return stored ? ServerConnection.Key.make(stored) : null
}, },
setDefaultServer: writeDefaultServerUrl, 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) { if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({ Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN, 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.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
"settings.general.row.terminalFont.title": "خط الطرفية", "settings.general.row.terminalFont.title": "خط الطرفية",
"settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية", "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.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها", "settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة", "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.font.description": "Personalize a fonte usada em blocos de código",
"settings.general.row.terminalFont.title": "Fonte do terminal", "settings.general.row.terminalFont.title": "Fonte do terminal",
"settings.general.row.terminalFont.description": "Personalize a fonte usada no 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.title": "Fonte da interface",
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface", "settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
"settings.general.row.followup.title": "Comportamento de acompanhamento", "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.font.description": "Prilagodi font koji se koristi u blokovima koda",
"settings.general.row.terminalFont.title": "Font terminala", "settings.general.row.terminalFont.title": "Font terminala",
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu", "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.title": "UI font",
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu", "settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
"settings.general.row.followup.title": "Ponašanje nadovezivanja", "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.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
"settings.general.row.terminalFont.title": "Terminalskrifttype", "settings.general.row.terminalFont.title": "Terminalskrifttype",
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen", "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.title": "UI-skrifttype",
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen", "settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
"settings.general.row.followup.title": "Opfølgningsadfærd", "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.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
"settings.general.row.terminalFont.title": "Terminalschriftart", "settings.general.row.terminalFont.title": "Terminalschriftart",
"settings.general.row.terminalFont.description": "Passe die im Terminal verwendete Schriftart an", "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.title": "UI-Schriftart",
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen", "settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
"settings.general.row.followup.title": "Verhalten bei Folgefragen", "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.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
"settings.general.row.theme.title": "Theme", "settings.general.row.theme.title": "Theme",
"settings.general.row.theme.description": "Customise how OpenCode is themed.", "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.title": "Code Font",
"settings.general.row.font.description": "Customise the font used in code blocks", "settings.general.row.font.description": "Customise the font used in code blocks",
"settings.general.row.terminalFont.title": "Terminal Font", "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.font.description": "Personaliza la fuente usada en bloques de código",
"settings.general.row.terminalFont.title": "Fuente del terminal", "settings.general.row.terminalFont.title": "Fuente del terminal",
"settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el 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.title": "Fuente de la interfaz",
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz", "settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
"settings.general.row.followup.title": "Comportamiento de seguimiento", "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.font.description": "Personnaliser la police utilisée dans les blocs de code",
"settings.general.row.terminalFont.title": "Police du terminal", "settings.general.row.terminalFont.title": "Police du terminal",
"settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le 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.title": "Police de l'interface",
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface", "settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
"settings.general.row.followup.title": "Comportement de suivi", "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.font.description": "コードブロックで使用するフォントをカスタマイズします",
"settings.general.row.terminalFont.title": "ターミナルのフォント", "settings.general.row.terminalFont.title": "ターミナルのフォント",
"settings.general.row.terminalFont.description": "ターミナルで使用するフォントをカスタマイズ", "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.title": "UIフォント",
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします", "settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作", "settings.general.row.followup.title": "フォローアップの動作",
-4
View File
@@ -580,10 +580,6 @@ export const dict = {
"settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정", "settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
"settings.general.row.terminalFont.title": "터미널 글꼴", "settings.general.row.terminalFont.title": "터미널 글꼴",
"settings.general.row.terminalFont.description": "터미널에서 사용할 글꼴을 설정합니다", "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.title": "UI 글꼴",
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정", "settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작", "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.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
"settings.general.row.terminalFont.title": "Terminalskrift", "settings.general.row.terminalFont.title": "Terminalskrift",
"settings.general.row.terminalFont.description": "Tilpass skrifttypen som brukes i terminalen", "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.title": "UI-skrift",
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet", "settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
"settings.general.row.followup.title": "Oppfølgingsadferd", "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.font.description": "Dostosuj czcionkę używaną w blokach kodu",
"settings.general.row.terminalFont.title": "Czcionka terminala", "settings.general.row.terminalFont.title": "Czcionka terminala",
"settings.general.row.terminalFont.description": "Dostosuj czcionkę używaną w terminalu", "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.title": "Czcionka interfejsu",
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie", "settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
"settings.general.row.followup.title": "Zachowanie kontynuacji", "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.font.description": "Настройте шрифт, используемый в блоках кода",
"settings.general.row.terminalFont.title": "Шрифт терминала", "settings.general.row.terminalFont.title": "Шрифт терминала",
"settings.general.row.terminalFont.description": "Настройте шрифт, используемый в терминале", "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.title": "Шрифт интерфейса",
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе", "settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
"settings.general.row.followup.title": "Поведение уточняющих вопросов", "settings.general.row.followup.title": "Поведение уточняющих вопросов",
-4
View File
@@ -771,10 +771,6 @@ export const dict = {
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด", "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
"settings.general.row.terminalFont.title": "ฟอนต์เทอร์มินัล", "settings.general.row.terminalFont.title": "ฟอนต์เทอร์มินัล",
"settings.general.row.terminalFont.description": "ปรับแต่งฟอนต์ที่ใช้ในเทอร์มินัล", "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.title": "ฟอนต์ UI",
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ", "settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล", "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.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
"settings.general.row.terminalFont.title": "Terminal yazı tipi", "settings.general.row.terminalFont.title": "Terminal yazı tipi",
"settings.general.row.terminalFont.description": "Terminalde kullanılan yazı tipini özelleştirin", "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.title": "Arayüz Yazı Tipi",
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin", "settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
"settings.general.row.followup.title": "Takip davranışı", "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.font.description": "Налаштуйте шрифт, який використовується в блоках коду",
"settings.general.row.terminalFont.title": "Шрифт термінала", "settings.general.row.terminalFont.title": "Шрифт термінала",
"settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі", "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.title": "Шрифт інтерфейсу",
"settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі", "settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі",
"settings.general.row.followup.title": "Поведінка продовження", "settings.general.row.followup.title": "Поведінка продовження",
-4
View File
@@ -768,10 +768,6 @@ export const dict = {
"settings.general.row.font.description": "自定义代码块使用的字体", "settings.general.row.font.description": "自定义代码块使用的字体",
"settings.general.row.terminalFont.title": "终端字体", "settings.general.row.terminalFont.title": "终端字体",
"settings.general.row.terminalFont.description": "自定义终端使用的字体", "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.title": "界面字体",
"settings.general.row.uiFont.description": "自定义整个界面使用的字体", "settings.general.row.uiFont.description": "自定义整个界面使用的字体",
"settings.general.row.followup.title": "跟进消息行为", "settings.general.row.followup.title": "跟进消息行为",
-4
View File
@@ -763,10 +763,6 @@ export const dict = {
"settings.general.row.font.description": "自訂程式碼區塊使用的字型", "settings.general.row.font.description": "自訂程式碼區塊使用的字型",
"settings.general.row.terminalFont.title": "終端機字型", "settings.general.row.terminalFont.title": "終端機字型",
"settings.general.row.terminalFont.description": "自訂終端機使用的字型", "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.title": "介面字型",
"settings.general.row.uiFont.description": "自訂整個介面使用的字型", "settings.general.row.uiFont.description": "自訂整個介面使用的字型",
"settings.general.row.followup.title": "後續追問行為", "settings.general.row.followup.title": "後續追問行為",
-56
View File
@@ -3,62 +3,6 @@
@import "@opencode-ai/ui/v2/styles/tailwind.css"; @import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-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-face {
font-family: "JetBrainsMono Nerd Font Mono"; font-family: "JetBrainsMono Nerd Font Mono";
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
+1 -4
View File
@@ -598,10 +598,7 @@ export function NewHome() {
} }
return ( return (
<div <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">
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"
>
<ScrollView <ScrollView
class="h-full [container-type:size]" class="h-full [container-type:size]"
thumbContainer={sessionThumbTrack} thumbContainer={sessionThumbTrack}
-1
View File
@@ -26,7 +26,6 @@ export default function NewLayout(props: ParentProps) {
return ( return (
<div <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" 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={{ style={{
"padding-top": "env(safe-area-inset-top, 0px)", "padding-top": "env(safe-area-inset-top, 0px)",
+1 -5
View File
@@ -2246,10 +2246,7 @@ export default function LegacyLayout(props: ParentProps) {
) )
return ( return (
<div <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">
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"
>
{autoselecting() ?? ""} {autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} /> <Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}> <Show when={updateVersion() !== undefined}>
@@ -2347,7 +2344,6 @@ export default function LegacyLayout(props: ParentProps) {
}} }}
> >
<main <main
data-background-surface="content"
classList={{ 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, "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()}> <Show when={projectController.selected()}>
<div <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" class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
classList={{ classList={{
"flex-col justify-center sm:flex-row": showWorkspaceBar(), "flex-col justify-center sm:flex-row": showWorkspaceBar(),
"w-fit max-w-full self-center": showWorkspaceBar(),
"justify-start": !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 }>) { function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
return ( return (
<div <div
data-background-surface={props.newLayout ? "panel" : undefined}
classList={{ classList={{
"flex-1 min-h-0 flex flex-col": true, "flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": props.newLayout, "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 { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection" 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) => export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -11,17 +9,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
yield* Effect.promise(async () => validateMiniTerminal()) yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server) const serverURL = Option.getOrUndefined(input.server)
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone }) 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(() => yield* Effect.promise(() =>
runMini({ runMini({
server: { server,
endpoint: server.endpoint,
reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined,
},
continue: input.continue, continue: input.continue,
session: Option.getOrUndefined(input.session), session: Option.getOrUndefined(input.session),
fork: input.fork, fork: input.fork,
@@ -31,7 +21,6 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replay: input.replay, replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit), replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo, demo: input.demo,
tuiConfig: resolved,
}), }),
) )
}), }),
+42 -9
View File
@@ -1,9 +1,8 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini" import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import fs from "node:fs" import fs from "node:fs"
import { readFile } from "node:fs/promises" import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { ReadStream } from "node:tty" import { ReadStream } from "node:tty"
@@ -15,17 +14,50 @@ export type InteractiveStdin = {
} }
type MiniHost = MiniFrontendInput["host"] 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"] { 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 { return {
async resolveVariant(model) { async resolveVariant(model) {
if (!model) return if (!model) return
return repository.resolveVariant(model) return (await read()).variant?.[variantKey(model)]
}, },
async saveVariant(model, variant) { async saveVariant(model, variant) {
if (!model) return 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( function createTrace(
logPath: string, logPath: string,
diagnostics: { pid: number; cwd: string; argv: string[] }, diagnostics: Pick<MiniHost["diagnostics"], "pid" | "cwd" | "argv">,
): MiniHost["diagnostics"]["trace"] { ): MiniHost["diagnostics"]["trace"] {
if (!process.env.OPENCODE_DIRECT_TRACE) return if (!process.env.OPENCODE_DIRECT_TRACE) return
const stamp = new Date() const stamp = new Date()
@@ -130,7 +162,7 @@ export async function usingInteractiveStdin<T>(
export function createMiniHost(input: { export function createMiniHost(input: {
terminal: InteractiveStdin terminal: InteractiveStdin
directory: string directory: string
paths?: { home: string; state: string; log: string } paths?: MiniHost["paths"]
}): MiniHost { }): MiniHost {
const paths = input.paths ?? { const paths = input.paths ?? {
home: Global.Path.home, home: Global.Path.home,
@@ -143,7 +175,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2), argv: process.argv.slice(2),
} }
return { return {
terminal: { stdin: input.terminal.stdin }, terminal: input.terminal,
platform: process.platform, platform: process.platform,
stdout: { stdout: {
write(value) { write(value) {
@@ -159,7 +191,7 @@ export function createMiniHost(input: {
return openEditor(options) return openEditor(options)
}, },
}, },
paths: { home: paths.home }, paths,
signals: { signals: {
sigint: signal("SIGINT"), sigint: signal("SIGINT"),
sigusr2: signal("SIGUSR2"), sigusr2: signal("SIGUSR2"),
@@ -169,6 +201,7 @@ export function createMiniHost(input: {
now: () => performance.now(), now: () => performance.now(),
}, },
diagnostics: { diagnostics: {
...diagnostics,
trace: createTrace(paths.log, diagnostics), trace: createTrace(paths.log, diagnostics),
}, },
preferences: preferences(paths.state), preferences: preferences(paths.state),
+70 -165
View File
@@ -1,17 +1,14 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { Service } from "@opencode-ai/client/effect/service"
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { MiniFrontendInput } from "@opencode-ai/tui/mini" import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { setTimeout } from "node:timers/promises" import { setTimeout } from "node:timers/promises"
import { ServerConnection } from "./services/server-connection"
import { waitForCatalogReady } from "./services/catalog" import { waitForCatalogReady } from "./services/catalog"
import { readStdin } from "./util/io" import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host" import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
export type MiniCommandInput = { export type MiniCommandInput = {
server: { server: ServerConnection.Resolved
endpoint: Endpoint
reconnect?: (signal: AbortSignal) => Promise<Endpoint>
}
continue?: boolean continue?: boolean
session?: string session?: string
fork?: boolean fork?: boolean
@@ -24,6 +21,7 @@ export type MiniCommandInput = {
tuiConfig?: MiniFrontendInput["tuiConfig"] tuiConfig?: MiniFrontendInput["tuiConfig"]
} }
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
type Model = MiniFrontendInput["model"] type Model = MiniFrontendInput["model"]
class MiniInputError extends Error {} 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 initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
const frontendTask = import("@opencode-ai/tui/mini") const frontendTask = import("@opencode-ai/tui/mini")
const directory = localDirectory() const directory = localDirectory()
const connection = createMiniConnection(input.server) const sdk = OpenCode.make({
const sdk = connection.sdk baseUrl: input.server.endpoint.url,
const requested = parseModel(input.model) headers: Service.headers(input.server.endpoint),
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined })
const prepare = prepareTarget(input.agent) const model = parseModel(input.model)
const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => { let agentTask: Promise<string | undefined> | undefined
const resolved = await resolveMiniTarget({ const resolveAgent = () => {
sdk: initial, agentTask ??= validateAgent(sdk, directory, input.agent)
reconnect: connection.reconnect, return agentTask
signal, }
resolve: (client) => const resolveSession = async () => {
resolveSessionTarget({ const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
client, const readyModel =
location: { directory }, model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
continue: input.continue, if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
session: input.session, const session = selected ?? (await createSession(sdk, directory, agent, model))
fork: input.fork, return { id: session.id, title: session.title, resume: selected !== undefined }
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 create = ( const create = (
client: OpenCodeClient, _sdk: OpenCodeClient,
next: { next: { agent: string | undefined; model: Model; variant: string | undefined },
location: { directory: string; workspaceID?: string } ) => createSession(sdk, directory, next.agent, next.model, next.variant)
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,
}))
const frontend = await frontendTask const frontend = await frontendTask
return frontend.runMiniFrontend({ return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory }), host: createMiniHost({ terminal, directory }),
sdk, sdk,
directory, directory,
target: resolveTarget, resolveAgent,
reconnect: connection.reconnect, session: resolveSession,
createSession: create, createSession: create,
agent: input.agent, agent: input.agent,
model, model,
variant: requested?.variant, variant: undefined,
files: [], files: [],
initialInput, initialInput,
thinking: true,
replay: input.replay ?? true, replay: input.replay ?? true,
replayLimit: input.replayLimit, replayLimit: input.replayLimit,
demo: input.demo, 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() { export function validateMiniTerminal() {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout") if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
} }
@@ -203,63 +112,28 @@ function localDirectory(): string {
} }
} }
function parseModel(value?: string) { function parseModel(value?: string): Model {
try { if (!value) return
return parseSessionTargetModel(value) const [providerID, ...rest] = value.split("/")
} catch { const modelID = rest.join("/")
throw new MiniInputError("--model must use the format provider/model[#variant]") if (!providerID || !modelID) throw new MiniInputError("--model must use the format provider/model")
} return { providerID, modelID }
} }
function prepareTarget(requestedAgent?: string): SessionTargetPreparation { async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
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,
) {
if (!name) return if (!name) return
const deadline = Date.now() + 5_000 const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline && !signal?.aborted) { while (Date.now() < deadline) {
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => { agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
const agent = agents?.data.find((item) => item.id === name) const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") { if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`) warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return return
} }
if (agent) return name if (agent) return name
await setTimeout(25, undefined, { signal }).catch(() => {}) await setTimeout(25)
} }
if (signal?.aborted) return
if (!agents) { if (!agents) {
warning("failed to list agents. Falling back to default agent") warning("failed to list agents. Falling back to default agent")
return return
@@ -267,6 +141,37 @@ async function validateAgent(
warning(`agent "${name}" not found. Falling back to default agent`) 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) { function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`) process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
} }
+45 -146
View File
@@ -1,11 +1,4 @@
import type { import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
EventSubscribeOutput,
JsonValue,
LLMToolContent,
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
} from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os" import { EOL } from "node:os"
import { readFile } from "node:fs/promises" import { readFile } from "node:fs/promises"
@@ -26,7 +19,6 @@ type File = {
type Input = { type Input = {
client: OpenCodeClient client: OpenCodeClient
sessionID: string sessionID: string
location: LocationRef
message: string message: string
files: File[] files: File[]
agent?: string 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. */ /** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean attached: boolean
compatibility?: "v1" compatibility?: "v1"
renderTool: (part: SessionMessageAssistantTool) => Promise<void> renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: SessionMessageAssistantTool) => Promise<void> renderToolError: (part: MiniToolPart) => Promise<void>
} }
type StartedPart = { type StartedPart = {
@@ -50,12 +42,9 @@ type StartedPart = {
type ToolState = StartedPart & { type ToolState = StartedPart & {
assistantMessageID: string assistantMessageID: string
tool: string tool: string
input: Record<string, JsonValue> input: Record<string, unknown>
raw?: string raw?: string
provider?: unknown provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
structured: Record<string, JsonValue>
content: LLMToolContent[]
} }
type V2Event = EventSubscribeOutput type V2Event = EventSubscribeOutput
@@ -78,6 +67,7 @@ export async function runNonInteractivePrompt(input: Input) {
let submitted = false let submitted = false
let promoted = false let promoted = false
let emittedError = false let emittedError = false
let questionRejected = false
let permissionRejected = false let permissionRejected = false
let formCancelled = false let formCancelled = false
let interrupted = 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">) => { 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 formCancelled = true
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
} }
const consume = async () => { const consume = async () => {
@@ -164,13 +152,15 @@ export async function runNonInteractivePrompt(input: Input) {
await replyPermission(event.data) await replyPermission(event.data)
continue continue
} }
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await rejectQuestion(event.data)
continue
}
if ( if (
event.type === "form.created" && event.type === "form.created" &&
submitted && submitted &&
(event.data.form.sessionID === input.sessionID || (event.data.form.sessionID === input.sessionID ||
(!input.attached && (!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID))
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
) { ) {
await cancelForm(event.data.form) await cancelForm(event.data.form)
continue continue
@@ -187,7 +177,7 @@ export async function runNonInteractivePrompt(input: Input) {
if ( if (
event.type === "session.execution.interrupted" && event.type === "session.execution.interrupted" &&
event.data.reason === "user" && event.data.reason === "user" &&
(interrupted || permissionRejected || formCancelled) (interrupted || permissionRejected || questionRejected || formCancelled)
) { ) {
return return
} }
@@ -270,32 +260,24 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.input.started") { if (event.type === "session.tool.input.started") {
flushStep() flushStep()
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), { tools.set(event.data.callID, {
id: partID(event.id), id: partID(event.id),
timestamp: time, timestamp: time,
assistantMessageID: event.data.assistantMessageID, assistantMessageID: event.data.assistantMessageID,
tool: event.data.name, tool: event.data.name,
input: {}, input: {},
structured: {},
content: [],
}) })
continue continue
} }
if (event.type === "session.tool.input.ended") { 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 if (current) current.raw = event.data.text
continue 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") { if (event.type === "session.tool.called") {
flushStep() flushStep()
const key = toolKey(event.data.assistantMessageID, event.data.callID) const current = tools.get(event.data.callID)
const current = tools.get(key) tools.set(event.data.callID, {
tools.set(key, {
id: current?.id ?? partID(event.id), id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time, timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID, assistantMessageID: event.data.assistantMessageID,
@@ -303,39 +285,11 @@ export async function runNonInteractivePrompt(input: Input) {
input: event.data.input, input: event.data.input,
raw: current?.raw, raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state }, provider: { executed: event.data.executed, state: event.data.state },
providerState: event.data.state,
structured: {},
content: [],
}) })
continue 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") { if (event.type === "session.tool.success") {
const key = toolKey(event.data.assistantMessageID, event.data.callID) const current = tools.get(event.data.callID) ?? fallbackTool(event)
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 part: MiniToolPart = { const part: MiniToolPart = {
id: current.id, id: current.id,
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -359,31 +313,13 @@ export async function runNonInteractivePrompt(input: Input) {
time: { start: current.timestamp, end: time }, time: { start: current.timestamp, end: time },
}, },
} }
tools.delete(key) tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) await input.renderTool(tool) if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue continue
} }
if (event.type === "session.tool.failed") { if (event.type === "session.tool.failed") {
const key = toolKey(event.data.assistantMessageID, event.data.callID) const current = tools.get(event.data.callID) ?? fallbackTool(event)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message 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 = { const part: MiniToolPart = {
id: current.id, id: current.id,
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -404,21 +340,10 @@ export async function runNonInteractivePrompt(input: Input) {
time: { start: current.timestamp, end: time }, time: { start: current.timestamp, end: time },
}, },
} }
tools.delete(key) tools.delete(event.data.callID)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) { if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, current.content).trim()) await input.renderToolError(part)
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured: current.structured,
content: current.content,
result: event.data.result,
},
})
await input.renderToolError(tool)
UI.error(error) UI.error(error)
} }
continue continue
@@ -448,7 +373,7 @@ export async function runNonInteractivePrompt(input: Input) {
v1InvalidOutput = true v1InvalidOutput = true
continue continue
} }
if (interrupted || permissionRejected || formCancelled) continue if (interrupted || permissionRejected || questionRejected || formCancelled) continue
flushStep() flushStep()
emittedError = true emittedError = true
process.exitCode = 1 process.exitCode = 1
@@ -456,9 +381,13 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.execution.failed") { if (event.type === "session.execution.failed") {
if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return if (
input.compatibility === "v1" &&
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
)
return
flushStep() flushStep()
if (!emittedError && !formCancelled) { if (!emittedError && !questionRejected && !formCancelled) {
emittedError = true emittedError = true
process.exitCode = 1 process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) 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 return
} }
if (event.type === "session.execution.interrupted") { 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" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) { if (event.data.reason !== "user" && !emittedError) {
emittedError = true emittedError = true
@@ -541,23 +470,19 @@ export async function runNonInteractivePrompt(input: Input) {
if (!response) return if (!response) return
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) 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.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined), input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached Promise.all(
? Promise.resolve(undefined) (input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
: input.client.form.request input.client.form.list({ sessionID }).catch(() => undefined),
.list({ ),
location: { directory: input.location.directory, workspace: input.location.workspaceID }, ),
})
.catch(() => undefined),
]) ])
await Promise.all([ await Promise.all([
...(permissions ?? []).map(replyPermission), ...(permissions ?? []).map(replyPermission),
...(forms ?? []).map(cancelForm), ...(questions ?? []).map(rejectQuestion),
...(globals && sameLocation(globals.location, input.location) ...forms.flatMap((response) => response ?? []).map(cancelForm),
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
: []),
]) ])
await completed await completed
} finally { } 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) { function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}` return `prt_${eventID.replace(/^evt_/, "")}`
} }
function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function fallbackTool(event: { function fallbackTool(event: {
id: string id: string
created: number created: number
@@ -606,8 +507,6 @@ function fallbackTool(event: {
assistantMessageID: event.data.assistantMessageID, assistantMessageID: event.data.assistantMessageID,
tool: "tool", tool: "tool",
input: {}, input: {},
structured: {},
content: [],
} }
} }
+72 -81
View File
@@ -1,13 +1,13 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises" import { open } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { readStdin } from "../util/io" import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection" import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "../services/catalog" import { waitForCatalogReady } from "../services/catalog"
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target" import { toolInlineInfo, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive" import { runNonInteractivePrompt } from "./noninteractive"
import { UI } from "./ui" import { UI } from "./ui"
@@ -47,15 +47,6 @@ type ExecutionOptions = {
compatibility?: "v1" compatibility?: "v1"
} }
class RunTargetError extends Error {
constructor(
message: string,
readonly sessionID?: string,
) {
super(message)
}
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
export function runNonInteractive(input: RunCommandInput) { 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) { async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) 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 explicit = parseRunModel(input.model)
const target = await resolveSessionTarget({ const explicitModel = explicit?.model
client, const variant = options.variant ?? explicit?.variant
location: prepared.directory ? { directory: prepared.directory } : undefined, const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
continue: input.continue, const defaultModel =
session: input.session, !explicitModel && !sessionModel
fork: input.fork, ? await client.model
model: explicit .default({ location: { directory: cwd, workspace } })
? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant } .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined, : undefined
agent: input.agent, const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
prepare: async (next) => { if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
const selected = if (model) {
next.model ?? await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
(await client.model const available = await client.model.list({ location: { directory: cwd, workspace } })
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
.then((result) => result.data)) return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
const model = selected }
? { const agent = await validateAgent(client, cwd, input.agent)
providerID: selected.providerID, const selected =
id: selected.id, session ??
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined), (await client.session.create({
} agent,
: undefined model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
if ((options.variant ?? explicit?.variant) && !model) location: { directory: cwd },
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id) }))
if (model) { if (!session && input.title !== undefined) {
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) {
await client.session.rename({ await client.session.rename({
sessionID: target.session.id, sessionID: selected.id,
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
}) })
} }
await runNonInteractivePrompt({ await runNonInteractivePrompt({
client, client,
sessionID: target.session.id, sessionID: selected.id,
location: target.location,
message: prepared.message, message: prepared.message,
files: prepared.files, files: prepared.files,
agent: target.agent, agent,
model, model,
variant, variant,
thinking: input.thinking ?? false, thinking: input.thinking ?? false,
@@ -156,9 +123,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
auto: input.auto ?? false, auto: input.auto ?? false,
attached: options.attached ?? true, attached: options.attached ?? true,
compatibility: options.compatibility, compatibility: options.compatibility,
renderTool: (part) => renderTool(part, target.location.directory), renderTool,
renderToolError: (part) => renderToolError(part, target.location.directory), renderToolError,
}).catch((error) => reportRunError(input, errorMessage(error), target.session.id)) }).catch((error) => reportRunError(input, errorMessage(error), selected.id))
} }
export function mergeInput(message: string | undefined, piped: string | undefined) { 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 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[]) { function formatMessage(message: string[]) {
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ") const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
return value || undefined return value || undefined
@@ -182,18 +160,18 @@ function localDirectory(root: string) {
} }
export function parseRunModel(value?: string) { export function parseRunModel(value?: string) {
const ref = parseSessionTargetModel(value) if (!value) return
if (!ref) return const ref = Model.Ref.parse(value)
return { return {
model: { providerID: ref.providerID, modelID: ref.id }, model: { providerID: ref.providerID, modelID: ref.id },
variant: ref.variant, 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 if (!name) return
const agents = await client.agent const agents = await client.agent
.list({ location: { directory, workspace } }) .list({ location: { directory } })
.then((result) => result.data) .then((result) => result.data)
.catch(() => undefined) .catch(() => undefined)
if (!agents) { if (!agents) {
@@ -212,6 +190,19 @@ async function validateAgent(client: OpenCodeClient, directory: string, workspac
return name 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> { async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
const file = path.resolve(directory, input) const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${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 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) { async function renderTool(part: MiniToolPart) {
const info = toolInlineInfo(part, directory) const info = toolInlineInfo(part)
if (info.mode === "block") { if (info.mode === "block") {
UI.empty() UI.empty()
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title) 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) { async function renderToolError(part: MiniToolPart) {
const info = toolInlineInfo(part, directory) const info = toolInlineInfo(part)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`) 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 // Location plugins initialize asynchronously, so explicit model selection must
// wait for that exact model before prompt admission. The execution path owns // wait for that exact model before prompt admission. The execution path owns
@@ -9,35 +9,14 @@ export async function waitForCatalogReady(input: {
workspace?: string workspace?: string
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string }
timeoutMs?: number timeoutMs?: number
signal?: AbortSignal
}) { }) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000) 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 const models = await input.sdk.model
.list( .list({ location: { directory: input.directory, workspace: input.workspace } })
{ location: { directory: input.directory, workspace: input.workspace } },
{ signal: input.signal },
)
.then((result) => result.data) .then((result) => result.data)
.catch((error) => { .catch(() => undefined)
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return 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: () => restart: () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Service.stop(options) 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 } from "opencode-drive"
import { defineScript, Llm } from "opencode-drive"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
export default defineScript({ export default defineScript({
launch: "manual", launch: "manual",
config: { autoupdate: false }, setup({ config }) {
run: ({ artifacts, llm, server }) => config.autoupdate = false
Effect.gen(function* () { },
yield* Effect.promise(() => configureServicePort(artifacts)) async run({ artifacts, llm, server, signal }) {
yield* server.launch() await configureServicePort(artifacts)
await server.launch()
const registration = yield* Effect.promise(() => serviceRegistration(artifacts)) const registration = await serviceRegistration(artifacts)
const root = path.resolve(import.meta.dir, "../../../..") 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 session = `mini-stage2-${process.pid}` const snapshots = path.join(artifacts, "mini-stage2")
const snapshots = path.join(artifacts, "mini-stage2") await mkdir(snapshots, { recursive: true })
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
yield* llm.queue( llm.queue(
Llm.toolCall({ llm.toolCall({
index: 0, index: 0,
id: "mini-shell", id: "mini-shell",
name: "shell", name: "shell",
input: { command: "printf 'drive-mini-tool-output\\n'" }, input: { command: "printf 'drive-mini-tool-output\\n'" },
}), }),
Llm.finish("tool-calls"), 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* () { llm.queue(
yield* Effect.uninterruptible( llm.toolCall({
Effect.promise(() => index: 0,
tmux([ id: "mini-slow-shell",
"new-session", name: "shell",
"-d", input: { command: "sleep 10" },
"-s", }),
session, llm.finish("tool-calls"),
"-x", )
"140", await tmux(["send-keys", "-t", session, "-l", "interrupt this turn"])
"-y", await Bun.sleep(100)
"30", await tmux(["send-keys", "-H", "-t", session, "0d"])
"--", await waitForPane(session, "$ sleep 10")
"env", await tmux(["send-keys", "-t", session, "Escape"])
`PWD=${path.join(artifacts, "files")}`, const armed = await waitForPane(session, "again to interrupt")
`OPENCODE_PASSWORD=${registration.password}`, await Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed)
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`, await tmux(["send-keys", "-t", session, "Escape"])
`OPENCODE_TEST_HOME=${artifacts}`, const interrupted = await waitForPane(session, "Step interrupted", 10_000)
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`, await Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted)
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`, if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
`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"]))
const first = yield* Effect.promise(() => waitForPane(session, "OpenCode")) await tmux(["send-keys", "-t", session, "C-c"])
yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first)) await waitForPane(session, "Press ctrl+c again to exit")
if (first.includes("drive mini response complete")) await tmux(["send-keys", "-t", session, "C-c"])
throw new Error("response rendered before prompt submission") await waitForDeadPane(session)
const status = await paneDeadStatus(session)
yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000)) if (status !== 0) throw new Error(`Mini exited with status ${status}`)
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"])) const exited = await capturePane(session)
yield* Effect.sleep(100) if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"])) throw new Error("Mini exit splash was not rendered before teardown")
const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000)) await Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered") } finally {
yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed)) signal.removeEventListener("abort", abort)
await tmux(["kill-session", "-t", session], true)
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))))
}),
}) })
/** @param {string[]} args */ /** @param {string[]} args */
+21 -5
View File
@@ -145,11 +145,11 @@ describe("Mini CLI host", () => {
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2) 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 directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory) 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(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function") expect(typeof input.files.readText).toBe("function")
const file = path.join(directory, "attachment.txt") 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(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
expect(typeof input.startup.showTiming).toBe("boolean") expect(typeof input.startup.showTiming).toBe("boolean")
expect(typeof input.startup.now()).toBe("number") 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 directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory) const input = host({ stdin: stream(true), cleanup() {} }, directory)
const file = path.join(directory, "model.json") 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") await input.preferences.saveVariant(model, "high")
expect(await input.preferences.resolveVariant(model)).toBe("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 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 Bun.write(file, "{")
await input.preferences.saveVariant(model, "high") 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 { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "node:path" import path from "node:path"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini" import { mergeInput as mergeInteractiveInput } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run" import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
import { parseSessionTargetModel } from "../src/session-target"
async function cli(args: string[]) { async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { 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") 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", () => { test("merges non-interactive argument and stdin input", () => {
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin") expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from 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", () => { test("parses model variants from the model reference", () => {
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe( expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }), 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 () => { 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 { 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" import { runNonInteractivePrompt } from "../../src/run/noninteractive"
type V2Event = EventSubscribeOutput type V2Event = EventSubscribeOutput
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"] type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
const location = { directory: "/work tree", workspaceID: "wrk_1" }
function ok<T>(data: T) { function ok<T>(data: T) {
return Promise.resolve(data) return Promise.resolve(data)
@@ -19,8 +18,8 @@ function form(id: string, sessionID: string): FormInfo {
} }
} }
function formCreated(info: FormInfo, eventLocation = location): V2Event { function formCreated(info: FormInfo): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } } return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } }
} }
function prompted(inputID: string): V2Event { 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 // Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID. // live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: { async function run(input: {
@@ -159,9 +100,6 @@ async function run(input: {
attached?: boolean attached?: boolean
format?: "default" | "json" format?: "default" | "json"
compatibility?: "v1" 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 sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }] const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
@@ -181,18 +119,10 @@ async function run(input: {
spyOn(sdk.event, "subscribe").mockImplementation(() => stream) spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "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( spyOn(sdk.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, (request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
) )
spyOn(sdk.form.request, "list").mockImplementation( spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
() =>
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.session, "prompt").mockImplementation((request) => { spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt" const messageID = request.id ?? "msg_prompt"
values.push(...input.turn(messageID)) values.push(...input.turn(messageID))
@@ -203,7 +133,6 @@ async function run(input: {
await runNonInteractivePrompt({ await runNonInteractivePrompt({
client: sdk, client: sdk,
sessionID: "ses_1", sessionID: "ses_1",
location,
message: "hello", message: "hello",
files: [], files: [],
thinking: false, thinking: false,
@@ -211,8 +140,8 @@ async function run(input: {
auto: false, auto: false,
attached: input.attached ?? false, attached: input.attached ?? false,
compatibility: input.compatibility, compatibility: input.compatibility,
renderTool: input.renderTool ?? (() => Promise.resolve()), renderTool: () => Promise.resolve(),
renderToolError: input.renderToolError ?? (() => Promise.resolve()), renderToolError: () => Promise.resolve(),
}) })
return sdk return sdk
} }
@@ -251,20 +180,9 @@ describe("runNonInteractivePrompt", () => {
// which must not leave the consume loop waiting forever. // which must not leave the consume loop waiting forever.
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")], turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
}) })
const globalOptions = { expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
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: "ses_1", formID: "frm_pending" }) 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.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
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()
}) })
test("attach mode cancels only session-owned forms", async () => { 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()], turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
}) })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.request.list).not.toHaveBeenCalled() expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything()) expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith( expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
}) })
test("V1 JSON output flushes step_start before an unrelated step failure", async () => { test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
@@ -335,69 +250,4 @@ describe("runNonInteractivePrompt", () => {
expect(output).toEqual({ stdout: "", stderr: "" }) 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 Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E> export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.add"]>[0] type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint11_1Input = { export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
readonly server: Endpoint11_1Request["params"]["server"] export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
readonly location?: Endpoint11_1Request["query"]["location"] export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
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>
export interface McpApi<E = never> { export interface McpApi<E = never> {
readonly list: McpListOperation<E> 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> } 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) => const Endpoint11_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_0Input) =>
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.add"]>[0] type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
type Endpoint11_1Input = { type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
readonly server: Endpoint11_1Request["params"]["server"] const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_1Input) =>
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) =>
raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.mcp"]) => ({ const adaptGroup11 = (raw: RawClient["server.mcp"]) => ({
list: Endpoint11_0(raw), list: Endpoint11_0(raw),
add: Endpoint11_1(raw), resource: { catalog: Endpoint11_1(raw) },
remove: Endpoint11_2(raw),
connect: Endpoint11_3(raw),
disconnect: Endpoint11_4(raw),
resource: { catalog: Endpoint11_5(raw) },
}) })
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
@@ -104,14 +104,6 @@ import type {
IntegrationCommandCancelOutput, IntegrationCommandCancelOutput,
McpListInput, McpListInput,
McpListOutput, McpListOutput,
McpAddInput,
McpAddOutput,
McpRemoveInput,
McpRemoveOutput,
McpConnectInput,
McpConnectOutput,
McpDisconnectInput,
McpDisconnectOutput,
McpResourceCatalogInput, McpResourceCatalogInput,
McpResourceCatalogOutput, McpResourceCatalogOutput,
CredentialUpdateInput, CredentialUpdateInput,
@@ -1052,55 +1044,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, 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: { resource: {
catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) => catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
request<McpResourceCatalogOutput>( request<McpResourceCatalogOutput>(
@@ -2482,14 +2482,6 @@ export type ProviderNotFoundError = {
export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError => export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "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 type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError => export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
@@ -3469,84 +3461,6 @@ export type McpListOutput = {
data: Array<McpServer> 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 = { export type McpResourceCatalogInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+136 -97
View File
@@ -1,30 +1,41 @@
# @opencode-ai/codemode # @opencode-ai/codemode
This is our take on code mode: a lightweight, pure interpreter for a JavaScript-like language built around calling This is our take on code mode. Programs are written in a lightweight, JavaScript-like DSL and run in the package's
tools. It supports familiar JavaScript syntax with a few key differences and limitations. See the own interpreter. They never execute as actual JavaScript, so there is no runtime to escape into. The interpreter
[interpreter support checklist](./interpreter-support.md) for more details. 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 [Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes
cannot directly access the network, filesystem, processes, or application APIs. They can interact with the outside generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application
world only through tools provided by the host, which can also limit execution time, tool calls, output size, and data. runs, no sandbox required.
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.
## How it differs from JavaScript ## How it differs from JavaScript
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs The deliberate differences:
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`.
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). [interpreter support checklist](./interpreter-support.md).
## Quick Start ## 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 ```ts
import { CodeMode, Tool } from "@opencode-ai/codemode" import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
@@ -37,133 +48,161 @@ const lookupOrder = Tool.make({
}) })
const runtime = CodeMode.make({ const runtime = CodeMode.make({
tools: { orders: { lookup: lookupOrder } }, tools: {
orders: {
lookup: lookupOrder,
},
},
}) })
const result = await Effect.runPromise( const result =
yield *
runtime.execute(` runtime.execute(`
const order = await tools.orders.lookup({ id: "order_42" }) const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" } 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 ## API
### `Tool.make` ### `Tool.make`
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON
shape the model-visible signature. Without `output`, the signature uses `Promise<unknown>`. 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 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.
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
`tools.context7["resolve-library-id"](...)`. `tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make` ### `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 ```ts
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } }) const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
runtime.catalog() // structured tool descriptions runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide 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 The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail. `onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are
Effect-returning and must not fail.
### OpenAPI tools ### OpenAPI tools
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values `OpenAPI.fromSpec` turns an OpenAPI 3.x document into namespaced tools - one tool per operation, using dotted
create namespaces: `operationId` segments as namespaces:
```ts ```ts
const api = OpenAPI.fromSpec({ spec, auth: { resolve } }) const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
const runtime = CodeMode.make({ tools: { opencode: api.tools } }) const runtime = CodeMode.make({ tools: { opencode: api.tools } })
``` ```
The synchronous result is `{ tools, skipped }`. Operations with unsupported parameter encodings, request bodies It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
without JSON content, WebSocket or SSE semantics, or binary responses are reported in `skipped`. 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`. ## Outputs
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.
## Results Every execution returns a `CodeMode.Result`:
Every execution returns:
```ts ```ts
type Result = type Result = Success | Failure
| {
readonly ok: true interface Success {
readonly value: CodeMode.DataValue readonly ok: true
readonly warnings?: ReadonlyArray<CodeMode.Diagnostic> readonly value: CodeMode.DataValue
readonly logs?: ReadonlyArray<string> readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
readonly truncated?: boolean readonly logs?: ReadonlyArray<string>
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall> readonly truncated?: boolean
} readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
| { }
readonly ok: false
readonly error: CodeMode.Diagnostic interface Failure {
readonly logs?: ReadonlyArray<string> readonly ok: false
readonly truncated?: boolean readonly error: CodeMode.Diagnostic
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall> 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` `value` is JSON-safe data. `warnings` are non-fatal diagnostics alongside a valid value (un-awaited rejections,
indicates that retained output was cut by `maxOutputBytes`. `toolCalls` retains admitted calls in order, including after timeout cleanup after the return). `logs` holds program console output, `truncated` marks any output-budget cut, and
failure. `toolCalls` lists admitted calls in order - retained on failure for auditing.
Diagnostic kinds: Failure `error` and success `warnings` share one diagnostic vocabulary:
| Kind | Meaning | | Kind | Meaning |
| ----------------------- | ---------------------------------------------------------------------------------------------- | | ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. | | `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. | | `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | The program referenced an unavailable tool. | | `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. | | `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output 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. | | `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | The program exceeded `maxToolCalls`. | | `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution timed out; as a warning, background work was interrupted after the program returned. | | `TimeoutExceeded` | Execution exceeded `timeoutMs`; as a warning, background work was interrupted after the program returned. |
| `ToolFailure` | A tool refused or failed. | | `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. | | `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning only: additional warnings were omitted by `maxOutputBytes`. | | `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 Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` is the explicit channel
safe refusal to the model; its optional cause remains private. for a model-visible refusal; its optional cause never crosses the boundary.
## Discovery ## Discovery
Generated instructions contain a tool catalog with a default budget of 2,000 estimated tokens. Configure it with The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with
`discovery: { catalogBudget }`. Every namespace remains visible, and the instructions say whether the catalog is `discovery: { catalogBudget }`): every namespace is always listed with its tool count, signatures are selected
complete or partial. 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:
The synchronous `search(...)` built-in is always available and advertised when the catalog is partial. It supports synchronous, deterministic field-weighted substring matching that returns directly callable paths with full
exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full signatures, supports namespace scoping and pagination, and treats an empty query as browsing and an exact path as
signatures. Search counts toward `maxToolCalls`. lookup. Search counts as an admitted tool call.
## Execution Limits ## Execution Limits
| Limit | Default | Controls | | Limit | Default | Bounds |
| ---------------- | --------- | ------------------------------- | | ---------------- | -------------------: | ---------------------------------------------------- |
| `timeoutMs` | unlimited | Total execution time. | | `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | unlimited | Admitted tool calls. | | `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | unlimited | Retained result value and logs. | | `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`. ## Boundaries and Non-Goals
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 The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy.
`TimeoutExceeded` warning. Tool-call concurrency is unrestricted. Boundary data is limited to 32 nested levels. 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. like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an - [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function. arrow function.
- [x] Promise-returning string replacers are coerced synchronously to `"[object Promise]"`, like JavaScript; they are - [ ] Stop automatically awaiting promise-returning string replacers; match JavaScript's synchronous callback-result
not automatically awaited. coercion.
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so - [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. ignoring it matches JS arrow-function semantics exactly.
- [ ] `this` in non-arrow CodeMode functions and callbacks. - [ ] `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] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`. - [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] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`. - [ ] `Array.prototype.toSpliced`.
- [x] Canonical array/string index parsing: keys such as `"01"` remain non-index properties rather than aliasing index - [ ] Canonical array/string index parsing: a key such as `"01"` must remain an ordinary property key rather than
`1`; arbitrary array-property assignment remains unsupported. aliasing index `1`.
- [x] `Array.prototype.sort` preserves trailing holes, while `toSorted` densifies holes into `undefined` elements, - [ ] `Array.prototype.sort` and `toSorted` must preserve trailing holes; they currently turn holes into own
like JavaScript. `undefined` elements.
## Strings ## Strings
@@ -266,10 +266,11 @@ ultimate source of truth.
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`. - [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`. - [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
- [ ] Date setters. - [ ] Date setters.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias. - [ ] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects. - [ ] 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. - [ ] 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 ## Regular expressions
@@ -278,7 +279,7 @@ ultimate source of truth.
- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`. - [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] 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] 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. - [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags.
- [ ] `RegExp.escape`. - [ ] `RegExp.escape`.
+9 -29
View File
@@ -428,14 +428,16 @@ const invokeStringReplacer = <R>(
let end = 0 let end = 0
for (const match of matches) { for (const match of matches) {
const replacement = yield* apply(match.args) 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. // Error values are branded plain objects; boundedData would strip the brand before coercion.
output.push( output.push(
value.slice(end, match.offset), value.slice(end, match.offset),
replacement instanceof CodeModePromise errorBrandName(resolved)
? "[object Promise]" ? coerceToString(resolved)
: errorBrandName(replacement) : coerceToString(boundedData(resolved, `String.${name} replacer result`)),
? coerceToString(replacement)
: coerceToString(boundedData(replacement, `String.${name} replacer result`)),
) )
end = match.offset + match.match.length end = match.offset + match.match.length
} }
@@ -662,20 +664,11 @@ const invokeArrayMethod = <R>(
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
case "reverse": case "reverse":
return Effect.succeed(target.reverse()) return Effect.succeed(target.reverse())
case "sort": { case "sort":
const length = target.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(target, index)).filter((own) => !own).length
const itemCount = length - holeCount
return Effect.map(sortArray(runner, target, args[0], "Array.sort", node), (sorted) => { return Effect.map(sortArray(runner, target, args[0], "Array.sort", node), (sorted) => {
sorted.slice(0, itemCount).forEach((item, index) => { target.splice(0, target.length, ...sorted)
target[index] = item
})
Array.from({ length: holeCount }, (_, index) => itemCount + index).forEach((index) => {
Reflect.deleteProperty(target, index)
})
return target return target
}) })
}
case "toSorted": case "toSorted":
return sortArray(runner, target, args[0], "Array.toSorted", node) return sortArray(runner, target, args[0], "Array.toSorted", node)
case "toReversed": case "toReversed":
@@ -714,19 +707,6 @@ const invokeArrayMethod = <R>(
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node) for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
return Effect.succeed(target.splice(start, deleteCount, ...inserted)) 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": { case "fill": {
rejectCircularInsertion(target, args[0], "Array.fill result", node) rejectCircularInsertion(target, args[0], "Array.fill result", node)
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) 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 { SafeObject } from "../tool-runtime.js"
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js" import type { CodeModePromise, CodeModeURL } from "../values.js"
export type SourcePosition = { export type SourcePosition = {
line: number line: number
@@ -35,7 +35,7 @@ export type StatementResult =
| { kind: "continue" } | { kind: "continue" }
export type MemberReference = { export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL target: SafeObject | Array<unknown> | CodeModeURL
key: string | number key: string | number
} }
+31 -67
View File
@@ -96,15 +96,6 @@ const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
URL: urlStatics, 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 => { const calleeDescription = (callee: AstNode): string => {
if (callee.type === "Identifier") return getString(callee, "name") if (callee.type === "Identifier") return getString(callee, "name")
if (callee.type === "MemberExpression") { if (callee.type === "MemberExpression") {
@@ -1104,7 +1095,7 @@ export class Interpreter<R> {
const args = yield* self.evaluateCallArguments(argNodes) const args = yield* self.evaluateCallArguments(argNodes)
switch (name) { switch (name) {
case "Date": case "Date":
return yield* self.constructDate(args, node) return self.constructDate(args)
case "RegExp": case "RegExp":
return self.constructRegExp(args, node) return self.constructRegExp(args, node)
case "Map": case "Map":
@@ -1142,37 +1133,17 @@ export class Interpreter<R> {
) )
} }
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> { private constructDate(args: Array<unknown>): CodeModeDate {
if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now())) if (args.length === 0) return new CodeModeDate(Date.now())
if (args.length === 1) { if (args.length === 1) {
const arg = args[0] const arg = args[0]
if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time)) if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time)
return Effect.map(this.toDatePrimitive(arg, node), (value) => if (typeof arg === "number") return new CodeModeDate(new Date(arg).getTime())
typeof value === "string" if (typeof arg === "string") return new CodeModeDate(Date.parse(arg))
? new CodeModeDate(Date.parse(value)) return new CodeModeDate(Number.NaN)
: new CodeModeDate(new Date(coerceToNumber(value)).getTime()),
)
} }
const parts = args.map((arg) => coerceToNumber(arg)) const parts = args.map((arg) => coerceToNumber(arg))
return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime())) return 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")
})
} }
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp { private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
@@ -1925,8 +1896,8 @@ export class Interpreter<R> {
if (typeof objectValue === "string") { if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length) if (key === "length") return new ComputedValue(objectValue.length)
const index = parseArrayIndex(key) if (typeof key === "number") return new ComputedValue(objectValue[key])
if (index !== undefined) return new ComputedValue(objectValue[index]) 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) if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined) return new ComputedValue(undefined)
} }
@@ -1958,7 +1929,6 @@ export class Interpreter<R> {
return new ComputedValue(undefined) return new ComputedValue(undefined)
} }
if (objectValue instanceof CodeModeRegExp) { if (objectValue instanceof CodeModeRegExp) {
if (key === "lastIndex") return { target: objectValue, key }
if (typeof key === "string" && regexpProperties.has(key)) { if (typeof key === "string" && regexpProperties.has(key)) {
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[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 (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key } if (operation === "delete") return { target: objectValue, key }
const index = parseArrayIndex(key) if (
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) { key !== "length" &&
!(typeof key === "string" && arrayMethods.has(key)) &&
typeof key !== "number" &&
!/^\d+$/.test(key)
) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) { if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key]) return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
} }
return new ComputedValue(undefined) return new ComputedValue(undefined)
} }
return { target: objectValue, key: index ?? key } return { target: objectValue, key }
} }
return { target: objectValue as SafeObject, key } return { target: objectValue as SafeObject, key }
@@ -2049,11 +2023,11 @@ export class Interpreter<R> {
) )
return reference return reference
if (Array.isArray(reference.target)) { if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key) return new IntrinsicReference(reference.target, reference.key)
return 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) { if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)] 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") 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) 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) throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
} }
} }
const key = Array.isArray(reference.target) ? reference.key : String(reference.key) const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
const { write, next, result } = yield* compute(self.readReferenceValue(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) if (write) self.assignToReference(reference, key, next, node)
return result 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 { private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) { if (Array.isArray(reference.target)) {
const target = reference.target const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) { const index = key as number
if (!Number.isInteger(index) || index < 0) {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
"Array assignment index must be a valid array index.", "Array assignment index must be a non-negative integer.",
node, node,
"InvalidDataValue", "InvalidDataValue",
) )
} }
rejectCircularInsertion(target, next, "Array assignment result", node) rejectCircularInsertion(target, next, "Array assignment result", node)
target[key] = next target[index] = next
return return
} }
if (reference.target instanceof CodeModeURL) { 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") 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 target = reference.target as SafeObject
const objectKey = key as string const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node) rejectCircularInsertion(target, next, "Object assignment result", node)
@@ -29,7 +29,6 @@ export const arrayMethods = new Set([
"shift", "shift",
"unshift", "unshift",
"splice", "splice",
"toSpliced",
"fill", "fill",
"copyWithin", "copyWithin",
"keys", "keys",
+1 -6
View File
@@ -4,8 +4,6 @@ export const dateMethods = new Set([
"toISOString", "toISOString",
"toJSON", "toJSON",
"toString", "toString",
"toUTCString",
"toGMTString",
"getFullYear", "getFullYear",
"getMonth", "getMonth",
"getDate", "getDate",
@@ -47,15 +45,12 @@ export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNod
case "valueOf": case "valueOf":
return value.time return value.time
case "toISOString": 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() return hosted.toISOString()
case "toJSON": case "toJSON":
return Number.isFinite(value.time) ? hosted.toISOString() : null return Number.isFinite(value.time) ? hosted.toISOString() : null
case "toString": case "toString":
return coerceToString(value) return coerceToString(value)
case "toUTCString":
case "toGMTString":
return hosted.toUTCString()
case "getFullYear": case "getFullYear":
return hosted.getFullYear() return hosted.getFullYear()
case "getMonth": case "getMonth":
+3 -18
View File
@@ -59,18 +59,9 @@ export const invokeRegExpMethod = (
): unknown => { ): unknown => {
switch (name) { switch (name) {
case "test": case "test":
return value.regex.test(coerceToString(args[0]))
case "exec": { case "exec": {
const input = coerceToString(args[0]) const matched = value.regex.exec(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
return matched === null ? null : matchToValue(matched) return matched === null ? null : matchToValue(matched)
} }
case "toString": case "toString":
@@ -79,13 +70,7 @@ export const invokeRegExpMethod = (
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node) 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 { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js" import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { CodeModeRegExp } from "../values.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) { constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags) 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 { 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.1_T1.js
* - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.2_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/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.js
* - test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js * - test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js
* - test/built-ins/Array/prototype/fill/return-this.js * - test/built-ins/Array/prototype/fill/return-this.js
@@ -64,8 +59,6 @@
* Copyright 2015 Microsoft Corporation. All rights reserved. * Copyright 2015 Microsoft Corporation. All rights reserved.
* Copyright 2016 The V8 project authors. All rights reserved. * Copyright 2016 The V8 project authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262. * 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 { describe, expect, test } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
@@ -234,31 +227,6 @@ const cases = [
code: `const input = ["first", "second", "third"]; const removed = input.splice(1); return [input, removed]`, code: `const input = ["first", "second", "third"]; const removed = input.splice(1); return [input, removed]`,
expected: [["first"], ["second", "third"]], 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", 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]]]`, 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") 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 () => { test("returned sparse arrays normalize holes to null at the host boundary", async () => {
expect(await value(`return Array(3)`)).toEqual([null, null, null]) 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", () => { 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 () => { 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].sort(Boolean)`)).toEqual([0, 0, 1])
expect(await value(`return [0, 1, 0].toSorted(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 () => { test("unknown property on a number is undefined", async () => {
expect(await value(`return (5).foo ?? "n"`)).toBe("n") 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)", () => { 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 () => { 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 () => { 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] === undefined`)).toBe(true)
expect(await value(`return [1,2,3][9]`)).toBeNull() 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", () => { 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 { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js" 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) 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 () => { 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 b - a`)).toBe(2000)
expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true) 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() expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
}) })
test("toISOString on an invalid date throws RangeError", async () => { test("toISOString on an invalid date is a catchable error", async () => {
expect(await value(`try { new Date("garbage").toISOString() } catch (error) { return error.name }`)).toBe( expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe(
"RangeError", "caught",
) )
}) })
@@ -180,25 +118,6 @@ describe("Date", () => {
expect(await value(`return typeof new Date(0)`)).toBe("object") expect(await value(`return typeof new Date(0)`)).toBe("object")
expect(await value(`return new Date(0).nope === undefined`)).toBe(true) 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", () => { describe("RegExp", () => {
@@ -235,51 +154,6 @@ describe("RegExp", () => {
).toEqual(["1", "22"]) ).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 () => { test("an unmatched string pattern returns null", async () => {
expect(await value(`return "abc".match(/\\d/)`)).toBeNull() expect(await value(`return "abc".match(/\\d/)`)).toBeNull()
}) })
@@ -324,7 +198,7 @@ describe("RegExp", () => {
).toBe("7null[object Object]") ).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({ const decorate = Tool.make({
description: "Decorate a string", description: "Decorate a string",
input: Schema.String, input: Schema.String,
@@ -337,7 +211,7 @@ describe("RegExp", () => {
code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`, 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( const missingAwait = await Effect.runPromise(
CodeMode.execute({ CodeMode.execute({
@@ -345,7 +219,8 @@ describe("RegExp", () => {
code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`, 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 () => { test("replaceAll without the g flag is a catchable error", async () => {
+48 -12
View File
@@ -1,19 +1,55 @@
export * as ConfigMCP from "./mcp" export * as ConfigMCP from "./mcp"
import { Schema } from "effect" 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 export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
// @opencode-ai/schema and is re-exported here. startup: PositiveInt.pipe(Schema.optional).annotate({
export const Timeout = Mcp.TimeoutConfig description: "Maximum time in milliseconds to establish and initialize the MCP server.",
export type Timeout = Mcp.TimeoutConfig }),
export const Local = Mcp.LocalConfig catalog: PositiveInt.pipe(Schema.optional).annotate({
export type Local = Mcp.LocalConfig description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
export const OAuth = Mcp.OAuthConfig }),
export type OAuth = Mcp.OAuthConfig execution: PositiveInt.pipe(Schema.optional).annotate({
export const Remote = Mcp.RemoteConfig description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
export type Remote = Mcp.RemoteConfig }),
export const Server = Mcp.ServerConfig }) {}
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")({ export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
timeout: Timeout.pipe(Schema.optional), 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 yield* Effect.forEach(
.registerBatch( registrations,
registrations.map((registration) => ({ (registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
tools: { [registration.name]: registration.tool }, { discard: true },
...(registration.options === undefined ? {} : { options: registration.options }), ).pipe(Effect.orDie)
})),
)
.pipe(Effect.orDie)
return { dispose: Effect.void } return { dispose: Effect.void }
}), }),
hook: (name, callback) => { hook: (name, callback) => {
@@ -401,14 +398,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
input: event.input, input: event.input,
result: event.result, result: event.result,
output: event.output, output: event.output,
outputPaths: event.outputPaths,
} }
return Reflect.apply(callback, undefined, [output]).pipe( return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() => Effect.tap(() =>
Effect.sync(() => { Effect.sync(() => {
event.result = output.result event.result = output.result
event.output = output.output event.output = output.output
event.outputPaths = output.outputPaths
}), }),
), ),
) )
+10 -21
View File
@@ -22,11 +22,6 @@ export interface BoundInput {
readonly output: ToolOutput readonly output: ToolOutput
} }
export interface BoundResult {
readonly output: ToolOutput
readonly outputPaths: ReadonlyArray<string>
}
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", { export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
operation: Schema.Literals(["encode", "write"]), operation: Schema.Literals(["encode", "write"]),
cause: Schema.Defect(), cause: Schema.Defect(),
@@ -41,7 +36,7 @@ export type Error = StorageError
export interface Interface { export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }> 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> readonly cleanup: () => Effect.Effect<void>
} }
@@ -150,26 +145,20 @@ const layer = Layer.effect(
lineCount(contextual) <= outputLimits.maxLines && lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
) )
return { return input.output
output: input.output,
outputPaths: [],
}
const outputPath = yield* write(contextual) const outputPath = yield* write(contextual)
const marker = `... output truncated; full content saved to ${outputPath} ...` const marker = `... output truncated; full content saved to ${outputPath} ...`
return { return {
output: { structured: input.output.structured,
structured: input.output.structured, content: [
content: [ {
{ type: "text" as const,
type: "text" as const, text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes), },
}, ...media,
...media, ],
],
},
outputPaths: [outputPath],
} }
}) })
-1
View File
@@ -55,4 +55,3 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
## Current Gaps ## Current Gaps
- MCP and future Session-scoped registrations still need an explicit canonical registration design. - 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