Compare commits

...

25 Commits

Author SHA1 Message Date
Dax Raad 1de1c51240 fix(console): remove desktop promo overlay 2026-07-20 15:23:48 +00:00
Aiden Cline 3da0dea8e7 feat(codemode): expand standard library parity (#37943) 2026-07-20 09:59:37 -05:00
Dax Raad c7aa47c144 feat(tui): implement session timeline 2026-07-20 09:53:07 -04:00
James Long 7be95bcd6a feat(tui): support single-mode themes (#37930) 2026-07-20 09:37:48 -04:00
opencode-agent[bot] 3f4fb3f9db fix(tui): show opencode2 continuation command (#37933)
Co-authored-by: Dax <mail@thdxr.com>
2026-07-20 13:36:02 +00:00
James Long 6702ce0d3f fix(tui): soften theme scale extremes (#37555) 2026-07-20 09:20:20 -04:00
Simon Klee a9895a1e8a fix(tui/mini): idle footer when session idles (#37910) 2026-07-20 13:22:46 +02:00
Simon Klee 75c7ac6a2c tui/mini: consolidate stream and panel internals (#37903) 2026-07-20 12:45:48 +02:00
Simon Klee 0eb71d0fc7 refactor(tui): extract shared frontend helpers (#37868) 2026-07-20 10:43:02 +02:00
Aiden Cline deee40c572 fix(codemode): parse canonical index keys (#37851) 2026-07-19 23:55:53 -05:00
Aiden Cline 8b5655ed53 fix(ai): preserve compatible reasoning details (#37708) 2026-07-19 22:30:55 -05:00
opencode-agent[bot] b9525b5878 fix(ai): expand context overflow patterns (#37848)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:22:56 -05:00
opencode-agent[bot] 2b8d1998d6 fix(ai): buffer partial tool call identity (#37847)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:12:49 -05:00
Aiden Cline 391cfbcfe7 fix(codemode): align string, array, and Date behavior (#37775) 2026-07-19 21:41:06 -05:00
Dax Raad fe0c74f4df fix(cli): accept updated service on restart 2026-07-19 19:29:40 -04:00
Kit Langton 6d32bc9cb0 feat(simulation): control arbitrary tool lifecycles (#37816) 2026-07-19 17:40:30 -04:00
Simon Klee 925c2423de mini: add reconnect, forms, and shared targets (#37811)
Centralize session target resolution for mini and noninteractive
run paths. Recover from transport drops, replace questions with
forms, and keep tool/catalog state location-scoped with live
progress and theme discovery.
2026-07-19 22:45:10 +02:00
Kit Langton 71cb419570 fix(simulation): validate semantic click identity (#37808) 2026-07-19 16:43:53 -04:00
Kit Langton edc93ceff1 feat(simulation): expose semantic UI snapshots (#37802) 2026-07-19 16:04:27 -04:00
opencode-agent[bot] 86a468c4d8 fix(tui): move agent cycling to shift-tab (#37706)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-19 21:45:03 +02:00
opencode-agent[bot] f05d2ab551 fix(tui): load root sessions in switcher (#37784)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-19 16:32:32 +00:00
opencode-agent[bot] bef6cfbffe feat(client): support opaque payload schemas (#37773)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 16:08:26 +00:00
Aiden Cline fe9a936867 feat(server): expose runtime MCP controls over HTTP (#37712) 2026-07-19 10:28:25 -05:00
Aiden Cline d5669ca934 docs(codemode): streamline README (#37769) 2026-07-19 10:18:24 -05:00
opencode-agent[bot] 04f0a771a3 feat(ai): add OpenAI image generation (#37714)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 10:17:49 -05:00
212 changed files with 13774 additions and 7501 deletions
+1
View File
@@ -902,6 +902,7 @@
"@napi-rs/canvas": "1.0.2",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"effect": "catalog:",
},
+43 -2
View File
@@ -1,6 +1,6 @@
# @opencode-ai/ai
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
```ts
import { Effect } from "effect"
@@ -24,6 +24,45 @@ const program = Effect.gen(function* () {
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Image generation
Use `Image.generate` with an image model for direct asset generation:
```ts
import { Image } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
const program = Effect.gen(function* () {
const response = yield* Image.generate({
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: 2,
size: { width: 1024, height: 1024 },
providerOptions: { openai: { quality: "high", outputFormat: "webp" } },
})
return response.images // GeneratedImage[] with owned bytes or a provider URL
})
```
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
```ts
const program = Effect.gen(function* () {
const response = yield* LLM.generate(
LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
prompt: "Design a solarpunk rooftop garden, then show me.",
tools: [OpenAI.imageGeneration({ quality: "high" })],
}),
)
return response.message
})
```
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
@@ -32,6 +71,8 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
## Caching
@@ -182,7 +223,7 @@ Adding a new model or deployment is usually 5-15 lines using `Route.make({ proto
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and 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 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.
## See also
+34
View File
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,116 @@
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,4 +1,5 @@
export { LLMClient } from "./route/client"
export { ImageClient } from "./image-client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
@@ -10,6 +11,9 @@ export type {
Service as LLMClientService,
} from "./route/client"
export * from "./schema"
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
export { Image } from "./image"
export { Tool, ToolFailure, toDefinitions } from "./tool"
export { ToolRuntime } from "./tool-runtime"
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
+1
View File
@@ -2,6 +2,7 @@ export * as AnthropicMessages from "./anthropic-messages"
export * as BedrockConverse from "./bedrock-converse"
export * as Gemini from "./gemini"
export * as OpenAIChat from "./openai-chat"
export * as OpenAIImages from "./openai-images"
export * as OpenAICompatibleChat from "./openai-compatible-chat"
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
export * as OpenAIResponses from "./openai-responses"
+147 -18
View File
@@ -77,6 +77,7 @@ const OpenAIChatMessage = Schema.Union([
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: optionalArray(Schema.Unknown),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
@@ -149,6 +150,7 @@ const OpenAIChatDelta = Schema.Struct({
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
@@ -164,13 +166,23 @@ export const OpenAIChatEvent = Schema.Struct({
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface PendingToolDelta {
readonly id?: string
readonly name?: string
readonly input: string
}
export interface ParserState {
readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
}
// =============================================================================
@@ -216,7 +228,15 @@ const openAICompatibleReasoningContent = (native: unknown) =>
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
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) {
@@ -260,19 +280,28 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
}
}
const text = reasoning.map((part) => part.text).join("")
const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (reasoning.length === 0) return
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningContent = (() => {
if (reasoning.length === 0) return nativeReasoning
if (field === "reasoning_content") return text
})()
return {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content:
reasoning.length === 0
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
: field === "reasoning_content"
? text
: undefined,
reasoning_content: reasoningContent,
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
reasoning_details: details,
}
})
@@ -423,6 +452,59 @@ const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
}
const detailText = (details: ReadonlyArray<unknown>) => {
const text = details.flatMap((detail) => {
if (!isRecord(detail)) return []
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
return [detail.summary]
return []
})
if (text.length > 0) return text.join("")
}
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
for (const detail of details) {
const previous = result.at(-1)
if (
!isRecord(previous) ||
previous.type !== "reasoning.text" ||
!isRecord(detail) ||
detail.type !== "reasoning.text" ||
conflictingReasoningTextDetails(previous, detail)
) {
result.push(detail)
continue
}
result[result.length - 1] = {
...previous,
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
signature: mergeDetailValue(previous.signature, detail.signature),
format: mergeDetailValue(previous.format, detail.format),
}
}
}
const mergeDetailValue = (previous: unknown, current: unknown) =>
previous || current || (previous !== undefined ? previous : current)
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
conflictingDetailValue(previous.id, current.id) ||
conflictingDetailValue(previous.index, current.index) ||
conflictingDetailValue(previous.format, current.format) ||
(Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
const conflictingDetailValue = (previous: unknown, current: unknown) =>
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
openai: {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
})
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
const events: LLMEvent[] = []
@@ -432,29 +514,56 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
let pendingTools = state.pendingTools
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta)
const reasoningField = state.reasoningField ?? reasoning?.field
if (reasoning)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
openai: { reasoningField: reasoningField ?? reasoning.field },
})
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (!state.lifecycle.text.has("text-0") && text !== undefined)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
for (const tool of toolDeltas) {
const current = tools[tool.index]
const pending = pendingTools[tool.index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
if (!current && (!id || !name)) {
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
continue
}
if (pending) {
pendingTools = { ...pendingTools }
delete pendingTools[tool.index]
}
const result = ToolStream.appendOrStart(
ADAPTER,
tools,
tool.index,
{ id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result)) return yield* result
@@ -463,6 +572,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
events.push(...result.events)
}
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// valid calls and malformed local calls settle independently.
const finished =
@@ -473,11 +585,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
usage,
finishReason,
lifecycle,
reasoningField,
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
},
events,
] as const
@@ -487,7 +603,16 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...state.toolCallEvents)
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
@@ -512,9 +637,13 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: undefined,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
}),
step,
onHalt: finishEvents,
+208
View File
@@ -0,0 +1,208 @@
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
+79 -21
View File
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Effect, Encoding, Schema } from "effect"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -25,6 +25,7 @@ import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-responses"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
@@ -113,11 +114,24 @@ const OpenAIResponsesTool = Schema.Struct({
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
})
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
const OpenAIResponsesImageGenerationTool = Schema.Struct({
type: Schema.tag("image_generation"),
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
input_fidelity: Schema.optional(Schema.Literals(["low", "high"])),
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
partial_images: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesTools = Schema.Union([OpenAIResponsesTool, OpenAIResponsesImageGenerationTool])
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTools>
const OpenAIResponsesToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({ type: Schema.tag("image_generation") }),
])
// Fields shared between the HTTP body and the WebSocket `response.create`
@@ -128,7 +142,7 @@ const OpenAIResponsesCoreFields = {
model: Schema.String,
input: Schema.Array(OpenAIResponsesInputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(OpenAIResponsesTool),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
@@ -194,6 +208,8 @@ const OpenAIResponsesStreamItem = Schema.Struct({
outputs: Schema.optional(Schema.Unknown),
server_label: Schema.optional(Schema.String),
output: Schema.optional(Schema.Unknown),
result: Schema.optional(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
error: Schema.optional(Schema.Unknown),
encrypted_content: optionalNull(Schema.String),
})
@@ -258,21 +274,41 @@ const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
}
const nativeImageTool = (tool: ToolDefinition) => {
const native = nativeImageToolInput(tool)
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
}
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
const native = nativeImageToolInput(tool)
if (native !== undefined) {
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
return yield* invalid("OpenAI Responses image generation tool options are invalid")
}
return {
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
}
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "required" as const,
tool: (name) => ({ type: "function" as const, name }),
tool: (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 => ({
@@ -420,6 +456,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const itemID = hostedToolItemID(part)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.name === "image_generation" && part.result.type === "content") {
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, lowerToolResultContentItem),
})
}
if (itemID) hostedToolReferences.add(itemID)
continue
}
@@ -485,10 +528,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -574,14 +617,29 @@ const isReasoningItem = (
// Round-trip the full item as the structured result so consumers can extract
// outputs / sources / status without re-decoding.
const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: OpenAIResponsesStreamItem) {
const isError = typeof item.error !== "undefined" && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
)
return {
type: "content" as const,
value: [
{
type: "file" as const,
uri: `data:image/${item.output_format ?? "png"};base64,${item.result}`,
mime: `image/${item.output_format ?? "png"}`,
},
],
}
}
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
}
})
const hostedToolEvents = (
const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* (
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
): ReadonlyArray<LLMEvent> => {
) {
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = openaiMetadata({ itemId: item.id })
return [
@@ -595,12 +653,12 @@ const hostedToolEvents = (
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: hostedToolResult(item),
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
]
}
})
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -847,7 +905,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
if (isHostedToolItem(item)) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(...hostedToolEvents(item))
events.push(...(yield* hostedToolEvents(item)))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const reasoningDelta = (
providerMetadata?: ProviderMetadata,
): State => {
const started = reasoningStart(state, events, id, providerMetadata)
events.push(LLMEvent.reasoningDelta({ id, text }))
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
return started
}
@@ -0,0 +1,20 @@
import { Schema } from "effect"
const dimensions = (value: string) => {
const match = /^(\d+)x(\d+)$/.exec(value)
if (!match) return undefined
return { width: Number(match[1]), height: Number(match[2]) }
}
export const Size = Schema.String.check(
Schema.makeFilter((value) => {
if (value === "auto") return undefined
const parsed = dimensions(value)
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
}),
)
export const OpenAIImage = {
Size,
} as const
@@ -140,8 +140,8 @@ export const appendOrStart = <K extends StreamKey>(
missingToolMessage: string,
): AppendOutcome<K> | LLMError => {
const current = tools[key]
const id = delta.id ?? current?.id
const name = delta.name ?? current?.name
const id = current?.id ?? delta.id
const name = current?.name ?? delta.name
if (!id || !name) return eventError(route, missingToolMessage)
const tool = {
+11 -1
View File
@@ -16,13 +16,17 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
/input token count.*exceeds the maximum/i,
/tokens in request more than max tokens allowed/i,
/maximum prompt length is \d+/i,
/reduce the length of the messages/i,
/maximum context length is \d+ tokens/i,
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
/exceeds the limit of \d+/i,
/exceeds the available context size/i,
/greater than the context length/i,
@@ -34,11 +38,17 @@ const patterns = [
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
/too many tokens/i,
/token limit exceeded/i,
]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
+57 -2
View File
@@ -1,12 +1,14 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { Route, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
export type { OpenAIImageOptions } from "../protocols/openai-images"
export const id = ProviderID.make("openai")
@@ -20,8 +22,44 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
readonly image?: ImageConfig
}
export interface ImageConfig {
readonly providerOptions?: OpenAIImageOptions
}
export interface ImageGenerationOptions {
readonly action?: "auto" | "generate" | "edit"
readonly background?: "auto" | "opaque" | "transparent"
readonly inputFidelity?: "low" | "high"
readonly outputCompression?: number
readonly outputFormat?: "png" | "jpeg" | "webp"
readonly partialImages?: number
readonly quality?: "auto" | "low" | "medium" | "high"
readonly size?: string
}
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
ToolDefinition.make({
name: "image_generation",
description: "Generate or edit an image using OpenAI's hosted image generation tool.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
native: {
openai: {
type: "image_generation",
action: options.action,
background: options.background,
input_fidelity: options.inputFidelity,
output_compression: options.outputCompression,
output_format: options.outputFormat,
partial_images: options.partialImages,
quality: options.quality,
size: options.size,
},
},
})
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
@@ -35,7 +73,7 @@ export interface Settings extends ProviderPackage.Settings {
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
const defaults = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, image: _image, ...rest } = input
return rest
}
@@ -55,6 +93,21 @@ export const configure = (input: Config = {}) => {
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
defaults: {
providerOptions:
input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
http: mergeHttpOptions(
input.http === undefined ? undefined : HttpOptions.make(input.http),
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
),
},
})
return {
id,
@@ -62,6 +115,7 @@ export const configure = (input: Config = {}) => {
responses,
responsesWebSocket,
chat,
image,
configure,
}
}
@@ -97,3 +151,4 @@ export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
export const image = provider.image
+25 -7
View File
@@ -41,13 +41,31 @@ export const protocol = Protocol.make({
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
Effect.map(
(body) =>
({
...body,
...bodyOptions(request.providerOptions?.openrouter),
}) as OpenRouterBody,
),
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
const messages = body.messages.map((message) => {
if (message.role !== "assistant") return message
const source = sourceAssistants[assistantIndex++]
const reasoning = source?.content
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
.join("")
const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
return {
...message,
reasoning_content: undefined,
reasoning_text: undefined,
reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
reasoning_details: reasoningDetails,
}
})
return {
...body,
messages,
...bodyOptions(request.providerOptions?.openrouter),
} as OpenRouterBody
}),
),
},
stream: OpenAIChat.protocol.stream,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -15,7 +15,7 @@ export type AuthError = CredentialError | LLMError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput {
readonly request: LLMRequest
readonly request: { readonly http?: HttpOptions }
readonly method: "POST" | "GET"
readonly url: string
readonly body: string
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+95
View File
@@ -0,0 +1,95 @@
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"))),
),
),
),
)
})
+24 -2
View File
@@ -3,8 +3,30 @@ import { isContextOverflow } from "../src"
import { classifyProviderFailure } from "../src/provider-error"
describe("provider error classification", () => {
test("classifies Z.AI GLM token limit messages as context overflow", () => {
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Too many tokens",
"Token limit exceeded",
]
expect(messages.every(isContextOverflow)).toBe(true)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
"Rate limit exceeded, please retry after 30 seconds.",
"Too many requests. Please slow down.",
]
expect(messages.some(isContextOverflow)).toBe(false)
})
test("classifies V1 plain-text rate limit fallbacks", () => {
+54 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { LLM, LLMEvent } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
@@ -83,6 +83,59 @@ describe("Cloudflare", () => {
}),
)
it.effect("preserves reasoning details for AI Gateway continuation", () =>
Effect.gen(function* () {
const model = CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "test-gateway",
apiKey: "test-token",
}).model("anthropic/claude-sonnet-4.6")
const details = [
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
]
const merged = [
{
type: "reasoning.text",
text: "Thinking",
signature: "signed",
format: "anthropic-claude-v1",
index: 0,
},
]
const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(
sseEvents(
deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
deltaChunk({ reasoning_details: [details[2]] }),
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
),
{ headers: { "content-type": "text/event-stream" } },
),
),
),
),
)
expect(response.reasoning).toBe("Thinking")
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
])
}),
)
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
@@ -1,10 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src"
import { LLM, LLMEvent, LLMResponse } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route"
import { recordedTests } from "../recorded-test"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
const cases = [
{
@@ -15,6 +17,7 @@ const cases = [
}).model("anthropic/claude-sonnet-4.6"),
requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning",
structured: true,
},
{
name: "Vercel AI Gateway",
@@ -26,6 +29,7 @@ const cases = [
}).model("anthropic/claude-sonnet-4.6"),
requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning",
structured: true,
},
] as const
@@ -57,11 +61,82 @@ for (const item of cases) {
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning" },
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
if (!item.structured) return
const details = metadata?.openai?.reasoningDetails
if (!Array.isArray(details)) return
expect(
details.some(
(detail) =>
typeof detail === "object" &&
detail !== null &&
"signature" in detail &&
typeof detail.signature === "string" &&
detail.signature.length > 0,
),
).toBe(true)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: item.model, messages: [response.message] }),
)
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
])
const replayDetails =
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
expect(Array.isArray(replayDetails)).toBe(true)
if (!Array.isArray(replayDetails)) return
expect(replayDetails).toEqual(details)
expect(replayDetails).toHaveLength(1)
expect(replayDetails[0]).toMatchObject({
type: "reasoning.text",
text: response.reasoning,
signature: expect.any(String),
})
}),
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,6 +570,375 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
]
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
],
},
finish_reason: "tool_calls",
},
],
},
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning: "thinking",
reasoning_details: details,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: '{"query":"weather"}' },
},
],
},
])
}),
)
it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
}),
)
it.effect("preserves unknown reasoning details while using scalar display text", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
}),
)
it.effect("uses scalar display text for signature-only reasoning details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
}),
)
it.effect("ignores scalar reasoning after content starts", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: { reasoning: "scalar" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("detail")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: details },
})
}),
)
it.effect("preserves an explicitly empty reasoning details array", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: [] },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
}),
)
it.effect("attaches signature-only details that arrive after content", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
]
const merged = [
{
type: "reasoning.text",
text: "thinking",
signature: "signed",
format: "anthropic-claude-v1",
index: 0,
},
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
])
}),
)
it.effect("preserves metadata-only reasoning when the stream ends", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
])
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}),
)
it.effect("flushes details-only display reasoning when the stream ends", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("summary")
expect(response.message.content).toEqual([
{ type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
])
}),
)
it.effect("replays details from multiple reasoning parts in order", () =>
Effect.gen(function* () {
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "first",
providerMetadata: { openai: { reasoningDetails: [first] } },
},
{
type: "reasoning",
text: "second",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
},
]),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
])
}),
)
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
Effect.gen(function* () {
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "A",
providerMetadata: { openai: { reasoningDetails: [detail] } },
},
{ type: "reasoning", text: "B" },
]),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
])
}),
)
it.effect("replays native scalar reasoning alongside native details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.make({
role: "assistant",
content: [{ type: "reasoning", text: "thinking" }],
native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
}),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
])
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -606,6 +975,67 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("ignores empty identity fields on later tool call deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
}),
deltaChunk({
tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
}),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
it.effect("buffers tool call deltas until the function name arrives", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
}),
deltaChunk({
tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
}),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
it.effect("fails when a buffered tool call never receives a function name", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
}),
deltaChunk({}, "tool_calls"),
)
const error = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
}),
)
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -0,0 +1,40 @@
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)
}),
)
})
@@ -0,0 +1,66 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src"
import { OpenAI } from "../../src/providers"
import { recordedTests } from "../recorded-test"
const openai = OpenAI.configure({
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
})
const recorded = recordedTests({
prefix: "openai-responses-images",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
})
describe("OpenAI Responses image generation recorded", () => {
recorded.effect("generates and edits an image with the hosted tool", () =>
Effect.gen(function* () {
const initial = Message.user("Generate a simple flat black triangle centered on a plain white background.")
const tools = [
OpenAI.imageGeneration({
action: "auto",
quality: "low",
size: "1024x1024",
outputFormat: "jpeg",
outputCompression: 10,
partialImages: 0,
}),
]
const response = yield* LLM.generate(
LLM.request({
model: openai.responses("gpt-5-mini"),
messages: [initial],
tools,
toolChoice: "image_generation",
}),
)
const result = response.events.find(LLMEvent.is.toolResult)
expect(result).toBeDefined()
expect(result?.providerExecuted).toBe(true)
expect(result?.result.type).toBe("content")
if (result?.result.type !== "content") return
expect(result.result.value).toHaveLength(1)
expect(result.result.value[0]?.type).toBe("file")
if (result.result.value[0]?.type !== "file") return
expect(result.result.value[0].mime).toBe("image/jpeg")
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
const edited = yield* LLM.generate(
LLM.request({
model: openai.responses("gpt-5-mini"),
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
tools,
toolChoice: "image_generation",
}),
)
const editedResult = edited.events.find(LLMEvent.is.toolResult)
expect(editedResult?.result.type).toBe("content")
if (editedResult?.result.type !== "content") return
expect(editedResult.result.value[0]?.type).toBe("file")
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -58,6 +58,39 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
tools: [OpenAI.imageGeneration({ action: "generate", quality: "high", size: "1024x1024" })],
toolChoice: "image_generation",
}),
)
expect(prepared.body.tools).toEqual([
{ type: "image_generation", action: "generate", quality: "high", size: "1024x1024" },
])
expect(prepared.body.tool_choice).toEqual({ type: "image_generation" })
}),
)
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
tools: [OpenAI.imageGeneration({ outputCompression: -1, partialImages: 4, size: "bogus" })],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("image generation tool options are invalid")
}),
)
it.effect("lowers semantic service tier options", () =>
Effect.gen(function* () {
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
@@ -1103,6 +1136,48 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.user("Generate a black triangle."),
Message.assistant([
ToolCallPart.make({
id: "ig_1",
name: "image_generation",
input: {},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
ToolResultPart.make({
id: "ig_1",
name: "image_generation",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
]),
Message.user("Make it blue."),
],
tools: [imageTool],
}),
)
expect(prepared.body.store).toBe(false)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Generate a black triangle." }] },
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
{ role: "user", content: [{ type: "input_text", text: "Make it blue." }] },
])
}),
)
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -1361,6 +1436,59 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () {
const item = {
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
providerExecuted: true,
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
})
}),
)
it.effect("rejects malformed image generation base64", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "image_generation_call", id: "ig_bad", status: "completed", result: "%%%" },
},
{ type: "response.completed", response: {} },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("invalid image base64")
}),
)
it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
Effect.gen(function* () {
const item = {
+99 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src"
import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
@@ -53,4 +53,102 @@ 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 }])
}),
)
})
+2 -23
View File
@@ -120,29 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
throw new Error("Weather tool loop exceeded 10 steps")
})
const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
const content: ContentPart[] = []
for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") {
const type = event.type === "text-delta" ? "text" : "reasoning"
const last = content.at(-1)
if (last?.type === type) {
content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
} else {
content.push({ type, text: event.text })
}
continue
}
if (event.type === "text-end" || event.type === "reasoning-end") {
const type = event.type === "text-end" ? "text" : "reasoning"
const last = content.at(-1)
if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
continue
}
if (event.type === "tool-call") content.push(event)
}
return content
}
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
export const expectFinish = (
events: ReadonlyArray<LLMEvent>,
+8 -2
View File
@@ -3,6 +3,8 @@ import { Layer } from "effect"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
import { ImageClient } from "../src/image-client"
import type { Service as ImageClientService } from "../src/image-client"
import type { Service as LLMClientService } from "../src/route/client"
import type { Service as RequestExecutorService } from "../src/route/executor"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
@@ -15,7 +17,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -81,6 +83,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
),
)
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
return Layer.mergeAll(
deps,
LLMClient.layer.pipe(Layer.provide(deps)),
ImageClient.layer.pipe(Layer.provide(deps)),
)
},
})
+27
View File
@@ -36,6 +36,33 @@ describe("ToolStream", () => {
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query"' },
"missing tool",
)
if (ToolStream.isError(first)) return yield* first
const second = ToolStream.appendOrStart(
ADAPTER,
first.tools,
0,
{ id: "", name: "", text: ':"weather"}' },
"missing tool",
)
if (ToolStream.isError(second)) return yield* second
const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
}),
)
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
+13 -2
View File
@@ -1,7 +1,9 @@
import { Effect, Option } from "effect"
import { Context, Effect, FileSystem, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { Config } from "../../config"
import { resolve } from "@opencode-ai/tui/config"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
@@ -9,9 +11,17 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server)
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
const config = yield* Config.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const service = server.service
yield* Effect.promise(() =>
runMini({
server,
server: {
endpoint: server.endpoint,
reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined,
},
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
@@ -21,6 +31,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
tuiConfig: resolved,
}),
)
}),
+9 -42
View File
@@ -1,8 +1,9 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import fs from "node:fs"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
@@ -14,50 +15,17 @@ export type InteractiveStdin = {
}
type MiniHost = MiniFrontendInput["host"]
type ModelState = Record<string, unknown> & {
variant?: Record<string, string | undefined>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function state(value: unknown): ModelState {
if (!isRecord(value)) return {}
const variant = isRecord(value.variant)
? Object.fromEntries(
Object.entries(value.variant).flatMap(([key, item]) =>
typeof item === "string" ? ([[key, item]] as const) : [],
),
)
: undefined
return { ...value, variant }
}
function variantKey(model: NonNullable<MiniFrontendInput["model"]>) {
return `${model.providerID}/${model.modelID}`
}
function preferences(statePath: string): MiniHost["preferences"] {
const file = path.join(statePath, "model.json")
const read = () =>
readFile(file, "utf8")
.then((value) => state(JSON.parse(value)))
.catch(() => state(undefined))
const repository = createModelPreferenceRepository(path.join(statePath, "model.json"))
return {
async resolveVariant(model) {
if (!model) return
return (await read()).variant?.[variantKey(model)]
return repository.resolveVariant(model)
},
async saveVariant(model, variant) {
if (!model) return
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(() => {})
await repository.saveVariant(model, variant).catch(() => undefined)
},
}
}
@@ -78,7 +46,7 @@ function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
function createTrace(
logPath: string,
diagnostics: Pick<MiniHost["diagnostics"], "pid" | "cwd" | "argv">,
diagnostics: { pid: number; cwd: string; argv: string[] },
): MiniHost["diagnostics"]["trace"] {
if (!process.env.OPENCODE_DIRECT_TRACE) return
const stamp = new Date()
@@ -162,7 +130,7 @@ export async function usingInteractiveStdin<T>(
export function createMiniHost(input: {
terminal: InteractiveStdin
directory: string
paths?: MiniHost["paths"]
paths?: { home: string; state: string; log: string }
}): MiniHost {
const paths = input.paths ?? {
home: Global.Path.home,
@@ -175,7 +143,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
terminal: input.terminal,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
write(value) {
@@ -191,7 +159,7 @@ export function createMiniHost(input: {
return openEditor(options)
},
},
paths,
paths: { home: paths.home },
signals: {
sigint: signal("SIGINT"),
sigusr2: signal("SIGUSR2"),
@@ -201,7 +169,6 @@ export function createMiniHost(input: {
now: () => performance.now(),
},
diagnostics: {
...diagnostics,
trace: createTrace(paths.log, diagnostics),
},
preferences: preferences(paths.state),
+165 -70
View File
@@ -1,14 +1,17 @@
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { setTimeout } from "node:timers/promises"
import { ServerConnection } from "./services/server-connection"
import { waitForCatalogReady } from "./services/catalog"
import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
export type MiniCommandInput = {
server: ServerConnection.Resolved
server: {
endpoint: Endpoint
reconnect?: (signal: AbortSignal) => Promise<Endpoint>
}
continue?: boolean
session?: string
fork?: boolean
@@ -21,7 +24,6 @@ export type MiniCommandInput = {
tuiConfig?: MiniFrontendInput["tuiConfig"]
}
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
type Model = MiniFrontendInput["model"]
class MiniInputError extends Error {}
@@ -33,42 +35,86 @@ export async function runMini(input: MiniCommandInput) {
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
const frontendTask = import("@opencode-ai/tui/mini")
const directory = localDirectory()
const sdk = OpenCode.make({
baseUrl: input.server.endpoint.url,
headers: Service.headers(input.server.endpoint),
})
const model = parseModel(input.model)
let agentTask: Promise<string | undefined> | undefined
const resolveAgent = () => {
agentTask ??= validateAgent(sdk, directory, input.agent)
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
const session = selected ?? (await createSession(sdk, directory, agent, model))
return { id: session.id, title: session.title, resume: selected !== undefined }
const connection = createMiniConnection(input.server)
const sdk = connection.sdk
const requested = parseModel(input.model)
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
const prepare = prepareTarget(input.agent)
const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => {
const resolved = await resolveMiniTarget({
sdk: initial,
reconnect: connection.reconnect,
signal,
resolve: (client) =>
resolveSessionTarget({
client,
location: { directory },
continue: input.continue,
session: input.session,
fork: input.fork,
model: requested,
agent: input.agent,
prepare,
signal,
}).catch((error) => {
if (error instanceof Error && error.message === "Session not found")
throw new MiniInputError(error.message)
throw error
}),
})
const target = resolved.value
return {
sdk: resolved.sdk,
sessionID: target.session.id,
sessionTitle: target.session.title,
location: target.location,
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
variant: target.model?.variant,
agent: target.agent,
resume: target.resume,
}
}
const create = (
_sdk: OpenCodeClient,
next: { agent: string | undefined; model: Model; variant: string | undefined },
) => createSession(sdk, directory, next.agent, next.model, next.variant)
client: OpenCodeClient,
next: {
location: { directory: string; workspaceID?: string }
agent: string | undefined
model: Model
variant: string | undefined
},
signal?: AbortSignal,
) =>
resolveSessionTarget({
client,
location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
: undefined,
prepare,
signal,
}).then((target) => ({
sessionID: target.session.id,
sessionTitle: target.session.title,
location: target.location,
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
variant: target.model?.variant,
agent: target.agent,
resume: false,
}))
const frontend = await frontendTask
return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory }),
sdk,
directory,
resolveAgent,
session: resolveSession,
target: resolveTarget,
reconnect: connection.reconnect,
createSession: create,
agent: input.agent,
model,
variant: undefined,
variant: requested?.variant,
files: [],
initialInput,
thinking: true,
replay: input.replay ?? true,
replayLimit: input.replayLimit,
demo: input.demo,
@@ -83,6 +129,51 @@ export async function runMini(input: MiniCommandInput) {
}
}
/** @internal Exported for CLI boundary tests. */
export function createMiniConnection(input: MiniCommandInput["server"]) {
const make = (endpoint: Endpoint) =>
OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const reconnect = input.reconnect
return {
sdk: make(input.endpoint),
reconnect: reconnect
? async (signal: AbortSignal) => {
const endpoint = await reconnect(signal)
return make(endpoint)
}
: undefined,
}
}
/** @internal Exported for reconnect lifecycle tests. */
export async function resolveMiniTarget<A>(input: {
sdk: OpenCodeClient
reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
signal: AbortSignal
resolve: (sdk: OpenCodeClient) => Promise<A>
}) {
let sdk = input.sdk
while (true) {
try {
return { sdk, value: await input.resolve(sdk) }
} catch (error) {
if (!input.reconnect || !(error instanceof ClientError) || error.reason !== "Transport") throw error
while (true) {
try {
sdk = await input.reconnect(input.signal)
break
} catch (resolveError) {
if (input.signal.aborted) throw resolveError
await setTimeout(250, undefined, { signal: input.signal })
}
}
}
}
}
export function validateMiniTerminal() {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
}
@@ -112,28 +203,63 @@ function localDirectory(): string {
}
}
function parseModel(value?: string): Model {
if (!value) return
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) throw new MiniInputError("--model must use the format provider/model")
return { providerID, modelID }
function parseModel(value?: string) {
try {
return parseSessionTargetModel(value)
} catch {
throw new MiniInputError("--model must use the format provider/model[#variant]")
}
}
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
return async (input) => {
if (input.model)
await waitForCatalogReady({
sdk: input.client,
directory: input.location.directory,
workspace: input.location.workspaceID,
model: { providerID: input.model.providerID, modelID: input.model.id },
signal: input.signal,
})
return {
model: input.model,
agent: requestedAgent
? await validateAgent(
input.client,
input.location.directory,
input.location.workspaceID,
requestedAgent,
input.signal,
)
: input.agent,
}
}
}
async function validateAgent(
sdk: OpenCodeClient,
directory: string,
workspace: string | undefined,
name?: string,
signal?: AbortSignal,
) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline) {
agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
while (Date.now() < deadline && !signal?.aborted) {
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
await setTimeout(25)
await setTimeout(25, undefined, { signal }).catch(() => {})
}
if (signal?.aborted) return
if (!agents) {
warning("failed to list agents. Falling back to default agent")
return
@@ -141,37 +267,6 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
warning(`agent "${name}" not found. Falling back to default agent`)
}
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
const selected =
preselected ??
(input.session
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await sdk.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined)
if (input.session && !selected) throw new MiniInputError("Session not found")
if (!selected) return
if (!input.fork) return selected
return sdk.session.fork({ sessionID: selected.id })
}
async function createSession(
sdk: OpenCodeClient,
directory: string,
agent: string | undefined,
model: Model,
variant?: string,
): Promise<Session> {
if (model) await waitForCatalogReady({ sdk, directory, model })
return sdk.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory },
})
}
function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
}
+146 -45
View File
@@ -1,4 +1,11 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type {
EventSubscribeOutput,
JsonValue,
LLMToolContent,
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
} from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
@@ -19,6 +26,7 @@ type File = {
type Input = {
client: OpenCodeClient
sessionID: string
location: LocationRef
message: string
files: File[]
agent?: string
@@ -30,8 +38,8 @@ type Input = {
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean
compatibility?: "v1"
renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: MiniToolPart) => Promise<void>
renderTool: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError: (part: SessionMessageAssistantTool) => Promise<void>
}
type StartedPart = {
@@ -42,9 +50,12 @@ type StartedPart = {
type ToolState = StartedPart & {
assistantMessageID: string
tool: string
input: Record<string, unknown>
input: Record<string, JsonValue>
raw?: string
provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
structured: Record<string, JsonValue>
content: LLMToolContent[]
}
type V2Event = EventSubscribeOutput
@@ -67,7 +78,6 @@ export async function runNonInteractivePrompt(input: Input) {
let submitted = false
let promoted = false
let emittedError = false
let questionRejected = false
let permissionRejected = false
let formCancelled = false
let interrupted = false
@@ -126,14 +136,16 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const rejectQuestion = async (request: { id: string }) => {
questionRejected = true
await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
}
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
try {
await input.client.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
formCancelled = true
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
}
const consume = async () => {
@@ -152,15 +164,13 @@ export async function runNonInteractivePrompt(input: Input) {
await replyPermission(event.data)
continue
}
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await rejectQuestion(event.data)
continue
}
if (
event.type === "form.created" &&
submitted &&
(event.data.form.sessionID === input.sessionID ||
(!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID))
(!input.attached &&
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
) {
await cancelForm(event.data.form)
continue
@@ -177,7 +187,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (
event.type === "session.execution.interrupted" &&
event.data.reason === "user" &&
(interrupted || permissionRejected || questionRejected || formCancelled)
(interrupted || permissionRejected || formCancelled)
) {
return
}
@@ -260,24 +270,32 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.input.started") {
flushStep()
tools.set(event.data.callID, {
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
structured: {},
content: [],
})
continue
}
if (event.type === "session.tool.input.ended") {
const current = tools.get(event.data.callID)
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.tool.input.delta") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = (current.raw ?? "") + event.data.delta
continue
}
if (event.type === "session.tool.called") {
flushStep()
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key)
tools.set(key, {
id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID,
@@ -285,11 +303,39 @@ export async function runNonInteractivePrompt(input: Input) {
input: event.data.input,
raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state },
providerState: event.data.state,
structured: {},
content: [],
})
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) {
current.structured = event.data.structured
current.content = event.data.content
}
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
providerResultState: event.data.resultState,
state: {
status: "completed",
input: current.input,
structured: event.data.structured,
content: event.data.content,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
@@ -313,13 +359,31 @@ export async function runNonInteractivePrompt(input: Input) {
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) await input.renderTool(part)
tools.delete(key)
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
providerResultState: event.data.resultState,
state: {
status: "error",
input: current.input,
structured: current.structured,
content: current.content,
error: event.data.error,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
@@ -340,10 +404,21 @@ export async function runNonInteractivePrompt(input: Input) {
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
tools.delete(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
await input.renderToolError(part)
if (toolOutputText(current.tool, current.content).trim())
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured: current.structured,
content: current.content,
result: event.data.result,
},
})
await input.renderToolError(tool)
UI.error(error)
}
continue
@@ -373,7 +448,7 @@ export async function runNonInteractivePrompt(input: Input) {
v1InvalidOutput = true
continue
}
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
if (interrupted || permissionRejected || formCancelled) continue
flushStep()
emittedError = true
process.exitCode = 1
@@ -381,13 +456,9 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.execution.failed") {
if (
input.compatibility === "v1" &&
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
)
return
if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return
flushStep()
if (!emittedError && !questionRejected && !formCancelled) {
if (!emittedError && !formCancelled) {
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
@@ -395,7 +466,7 @@ export async function runNonInteractivePrompt(input: Input) {
return
}
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
@@ -470,19 +541,23 @@ export async function runNonInteractivePrompt(input: Input) {
if (!response) return
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, questions, forms] = await Promise.all([
const [permissions, forms, globals] = await Promise.all([
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
Promise.all(
(input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
input.client.form.list({ sessionID }).catch(() => undefined),
),
),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached
? Promise.resolve(undefined)
: input.client.form.request
.list({
location: { directory: input.location.directory, workspace: input.location.workspaceID },
})
.catch(() => undefined),
])
await Promise.all([
...(permissions ?? []).map(replyPermission),
...(questions ?? []).map(rejectQuestion),
...forms.flatMap((response) => response ?? []).map(cancelForm),
...(forms ?? []).map(cancelForm),
...(globals && sameLocation(globals.location, input.location)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
: []),
])
await completed
} finally {
@@ -492,10 +567,34 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
function sameLocation(left: LocationRef | undefined, right: LocationRef) {
return !!left && left.directory === right.directory && left.workspaceID === right.workspaceID
}
function formRequestOptions(location: LocationRef | undefined): [] | [{ headers: Record<string, string> }] {
if (!location) return []
return [
{
headers: {
"x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
},
},
]
}
function formAlreadySettled(error: unknown) {
return !!error && typeof error === "object" && Reflect.get(error, "_tag") === "FormAlreadySettledError"
}
function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function fallbackTool(event: {
id: string
created: number
@@ -507,6 +606,8 @@ function fallbackTool(event: {
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
structured: {},
content: [],
}
}
+81 -72
View File
@@ -1,13 +1,13 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "../services/catalog"
import { toolInlineInfo, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
import { UI } from "./ui"
@@ -47,6 +47,15 @@ type ExecutionOptions = {
compatibility?: "v1"
}
class RunTargetError extends Error {
constructor(
message: string,
readonly sessionID?: string,
) {
super(message)
}
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
export function runNonInteractive(input: RunCommandInput) {
@@ -72,50 +81,74 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory")
const session = await selectSession(client, requestedDirectory, input)
const cwd = session?.location.directory ?? requestedDirectory
const workspace = session?.location.workspaceID
const explicit = parseRunModel(input.model)
const explicitModel = explicit?.model
const variant = options.variant ?? explicit?.variant
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
const defaultModel =
!explicitModel && !sessionModel
? await client.model
.default({ location: { directory: cwd, workspace } })
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
}
const agent = await validateAgent(client, cwd, input.agent)
const selected =
session ??
(await client.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory: cwd },
}))
if (!session && input.title !== undefined) {
const target = await resolveSessionTarget({
client,
location: prepared.directory ? { directory: prepared.directory } : undefined,
continue: input.continue,
session: input.session,
fork: input.fork,
model: explicit
? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant }
: undefined,
agent: input.agent,
prepare: async (next) => {
const selected =
next.model ??
(await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data))
const model = selected
? {
providerID: selected.providerID,
id: selected.id,
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
}
: undefined
if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
if (model) {
await waitForCatalogReady({
sdk: client,
directory: next.location.directory,
workspace: next.location.workspaceID,
model: { providerID: model.providerID, modelID: model.id },
})
const available = await client.model.list({
location: { directory: next.location.directory, workspace: next.location.workspaceID },
})
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
}
return {
model,
agent: input.agent
? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent)
: next.agent,
}
},
}).catch((error) => {
if (!(error instanceof RunTargetError)) throw error
reportRunError(input, error.message, error.sessionID)
return undefined
})
if (!target) return
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
const variant = target.model?.variant
if (!target.resume && input.title !== undefined) {
await client.session.rename({
sessionID: selected.id,
sessionID: target.session.id,
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
}
await runNonInteractivePrompt({
client,
sessionID: selected.id,
sessionID: target.session.id,
location: target.location,
message: prepared.message,
files: prepared.files,
agent,
agent: target.agent,
model,
variant,
thinking: input.thinking ?? false,
@@ -123,9 +156,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
auto: input.auto ?? false,
attached: options.attached ?? true,
compatibility: options.compatibility,
renderTool,
renderToolError,
}).catch((error) => reportRunError(input, errorMessage(error), selected.id))
renderTool: (part) => renderTool(part, target.location.directory),
renderToolError: (part) => renderToolError(part, target.location.directory),
}).catch((error) => reportRunError(input, errorMessage(error), target.session.id))
}
export function mergeInput(message: string | undefined, piped: string | undefined) {
@@ -134,17 +167,6 @@ export function mergeInput(message: string | undefined, piped: string | undefine
return message + "\n" + piped
}
export function pickRunModel(
explicit: { providerID: string; modelID: string } | undefined,
variant: string | undefined,
session: { providerID: string; modelID: string } | undefined,
fallback: { providerID: string; modelID: string } | undefined,
) {
if (explicit) return explicit
if (!variant) return
return session ?? fallback
}
function formatMessage(message: string[]) {
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
return value || undefined
@@ -160,18 +182,18 @@ function localDirectory(root: string) {
}
export function parseRunModel(value?: string) {
if (!value) return
const ref = Model.Ref.parse(value)
const ref = parseSessionTargetModel(value)
if (!ref) return
return {
model: { providerID: ref.providerID, modelID: ref.id },
variant: ref.variant,
}
}
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {
async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) {
if (!name) return
const agents = await client.agent
.list({ location: { directory } })
.list({ location: { directory, workspace } })
.then((result) => result.data)
.catch(() => undefined)
if (!agents) {
@@ -190,19 +212,6 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s
return name
}
async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
const selected = input.session
? await client.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await client.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined
if (input.session && !selected) fail("Session not found")
if (!selected || !input.fork) return selected
return client.session.fork({ sessionID: selected.id })
}
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
@@ -244,8 +253,8 @@ function isBinaryContent(bytes: Uint8Array) {
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
}
async function renderTool(part: MiniToolPart) {
const info = toolInlineInfo(part)
async function renderTool(part: SessionMessageAssistantTool, directory: string) {
const info = toolInlineInfo(part, directory)
if (info.mode === "block") {
UI.empty()
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
@@ -260,8 +269,8 @@ async function renderTool(part: MiniToolPart) {
)
}
async function renderToolError(part: MiniToolPart) {
const info = toolInlineInfo(part)
async function renderToolError(part: SessionMessageAssistantTool, directory: string) {
const info = toolInlineInfo(part, directory)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
+26 -5
View File
@@ -1,4 +1,4 @@
import type { OpenCodeClient } from "@opencode-ai/client/promise"
import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
// Location plugins initialize asynchronously, so explicit model selection must
// wait for that exact model before prompt admission. The execution path owns
@@ -9,14 +9,35 @@ export async function waitForCatalogReady(input: {
workspace?: string
model: { providerID: string; modelID: string }
timeoutMs?: number
signal?: AbortSignal
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline) {
while (Date.now() < deadline && !input.signal?.aborted) {
const models = await input.sdk.model
.list({ location: { directory: input.directory, workspace: input.workspace } })
.list(
{ location: { directory: input.directory, workspace: input.workspace } },
{ signal: input.signal },
)
.then((result) => result.data)
.catch(() => undefined)
.catch((error) => {
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
await new Promise((resolve) => setTimeout(resolve, 25))
await wait(25, input.signal)
}
}
function wait(delay: number, signal?: AbortSignal) {
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
if (signal.aborted) return Promise.resolve()
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal?.removeEventListener("abort", done)
resolve()
}
})
}
@@ -56,7 +56,7 @@ function managedService(options: EnsureOptions) {
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.ensure(options)
yield* Service.ensure(reconnectOptions)
}),
}
}
+166
View File
@@ -0,0 +1,166 @@
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 }] : []
}
+147 -117
View File
@@ -1,132 +1,162 @@
import { defineScript } from "opencode-drive"
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
import { mkdir } from "node:fs/promises"
import path from "node:path"
export default defineScript({
launch: "manual",
setup({ config }) {
config.autoupdate = false
},
async run({ artifacts, llm, server, signal }) {
await configureServicePort(artifacts)
await server.launch()
config: { autoupdate: false },
run: ({ artifacts, llm, server }) =>
Effect.gen(function* () {
yield* Effect.promise(() => configureServicePort(artifacts))
yield* server.launch()
const registration = await serviceRegistration(artifacts)
const root = path.resolve(import.meta.dir, "../../../..")
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
await mkdir(snapshots, { recursive: true })
const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
const root = path.resolve(import.meta.dir, "../../../..")
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
llm.queue(
llm.toolCall({
index: 0,
id: "mini-shell",
name: "shell",
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
llm.finish("tool-calls"),
)
llm.queue(llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
const abort = () => {
void tmux(["kill-session", "-t", session], true).catch(() => {})
}
signal.addEventListener("abort", abort, { once: true })
try {
await tmux([
"new-session",
"-d",
"-s",
session,
"-x",
"140",
"-y",
"30",
"--",
"env",
`PWD=${path.join(artifacts, "files")}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
"--preload=@opentui/solid/preload",
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
"--model",
"simulation/gpt-sim-model",
])
await tmux(["set-option", "-t", session, "remain-on-exit", "on"])
const first = await waitForPane(session, "OpenCode")
await Bun.write(path.join(snapshots, "01-first-paint.txt"), first)
if (first.includes("drive mini response complete")) throw new Error("response rendered before prompt submission")
await waitForPane(session, "Simulated Model", 15_000)
await tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"])
await Bun.sleep(100)
await tmux(["send-keys", "-H", "-t", session, "0d"])
const completed = await waitForPane(session, "drive mini response complete", 20_000)
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
await Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed)
await Bun.sleep(500)
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
await tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`])
await tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"])
await waitForFile(
resizeOutput,
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
)
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)
llm.queue(
llm.toolCall({
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-slow-shell",
id: "mini-shell",
name: "shell",
input: { command: "sleep 10" },
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
llm.finish("tool-calls"),
Llm.finish("tool-calls"),
)
await tmux(["send-keys", "-t", session, "-l", "interrupt this turn"])
await Bun.sleep(100)
await tmux(["send-keys", "-H", "-t", session, "0d"])
await waitForPane(session, "$ sleep 10")
await tmux(["send-keys", "-t", session, "Escape"])
const armed = await waitForPane(session, "again to interrupt")
await Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed)
await tmux(["send-keys", "-t", session, "Escape"])
const interrupted = await waitForPane(session, "Step interrupted", 10_000)
await Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted)
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
await tmux(["send-keys", "-t", session, "C-c"])
await waitForPane(session, "Press ctrl+c again to exit")
await tmux(["send-keys", "-t", session, "C-c"])
await waitForDeadPane(session)
const status = await paneDeadStatus(session)
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
const exited = await capturePane(session)
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
await Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)
} finally {
signal.removeEventListener("abort", abort)
await tmux(["kill-session", "-t", session], true)
}
},
const journey = Effect.gen(function* () {
yield* Effect.uninterruptible(
Effect.promise(() =>
tmux([
"new-session",
"-d",
"-s",
session,
"-x",
"140",
"-y",
"30",
"--",
"env",
`PWD=${path.join(artifacts, "files")}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
"--model",
"simulation/gpt-sim-model",
]),
),
)
yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
if (first.includes("drive mini response complete"))
throw new Error("response rendered before prompt submission")
yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
yield* Effect.sleep(500)
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
yield* Effect.promise(() =>
waitForFile(
resizeOutput,
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
),
)
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
const resized = yield* Effect.promise(() => captureVisiblePane(session))
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-question",
name: "question",
input: {
questions: [
{
header: "Drive form",
question: "Choose the Mini Form answer",
options: [{ label: "Accepted", description: "Continue the run" }],
multiple: false,
},
],
},
}),
Llm.finish("tool-calls"),
)
yield* llm.queue(Llm.text("drive mini form complete"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-slow-shell",
name: "shell",
input: { command: "sleep 10" },
}),
Llm.finish("tool-calls"),
)
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
yield* Effect.promise(async () => {
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
})
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
const status = yield* Effect.promise(() => paneDeadStatus(session))
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
const exited = yield* Effect.promise(() => capturePane(session))
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
})
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
}),
})
/** @param {string[]} args */
+5 -21
View File
@@ -145,11 +145,11 @@ describe("Mini CLI host", () => {
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
})
test("passes paths, platform, timing, and diagnostic context", async () => {
test("passes frontend host capabilities", async () => {
const directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory)
expect(input.paths).toEqual({ home: directory, state: directory, log: directory })
expect(input.paths).toEqual({ home: directory })
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory, "attachment.txt")
@@ -157,37 +157,21 @@ describe("Mini CLI host", () => {
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
expect(typeof input.startup.showTiming).toBe("boolean")
expect(typeof input.startup.now()).toBe("number")
expect(input.diagnostics).toMatchObject({ pid: process.pid, cwd: directory })
})
test("merges, clears, and repairs persisted model variants", async () => {
test("delegates model variant preferences", async () => {
const directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory)
const file = path.join(directory, "model.json")
await Bun.write(
file,
JSON.stringify({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low", invalid: 42 },
}),
)
await input.preferences.saveVariant(model, "high")
expect(await input.preferences.resolveVariant(model)).toBe("high")
expect(await Bun.file(file).json()).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low", "openai/gpt-5": "high" },
})
await input.preferences.saveVariant(model, undefined)
await input.preferences.saveVariant(model, "default")
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
expect(await Bun.file(file).json()).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low" },
})
await Bun.write(file, "{")
await input.preferences.saveVariant(model, "high")
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
expect(await input.preferences.resolveVariant(model)).toBe("high")
})
})
+80 -13
View File
@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "node:path"
import { mergeInput as mergeInteractiveInput } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
import { parseSessionTargetModel } from "../src/session-target"
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -24,26 +26,91 @@ describe("mini command", () => {
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
})
test("constructs a fresh authenticated client for a replacement endpoint", async () => {
const authorization: Array<string | null> = []
const initial = Bun.serve({
port: 0,
fetch() {
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
},
})
const replacement = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization"))
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
},
})
const controller = new AbortController()
let signal: AbortSignal | undefined
try {
const connection = createMiniConnection({
endpoint: { url: initial.url.toString() },
reconnect: async (next) => {
signal = next
return {
url: replacement.url.toString(),
auth: { type: "basic", username: "replacement", password: "secret" },
}
},
})
const client = await connection.reconnect?.(controller.signal)
if (!client) throw new Error("Expected a replacement client")
await client.health.get()
expect(client).not.toBe(connection.sdk)
expect(signal).toBe(controller.signal)
expect(authorization).toEqual([`Basic ${btoa("replacement:secret")}`])
expect(createMiniConnection({ endpoint: { url: initial.url.toString() } }).reconnect).toBeUndefined()
} finally {
initial.stop(true)
replacement.stop(true)
}
})
test("re-resolves a managed target when the endpoint moves before transport construction", async () => {
const initial = OpenCode.make({ baseUrl: "https://initial.opencode.test" })
const replacement = OpenCode.make({ baseUrl: "https://replacement.opencode.test" })
const controller = new AbortController()
const seen: (typeof initial)[] = []
let reconnects = 0
const result = await resolveMiniTarget({
sdk: initial,
reconnect: async (signal) => {
expect(signal).toBe(controller.signal)
reconnects++
if (reconnects === 1) throw new Error("service still moving")
return replacement
},
signal: controller.signal,
resolve: async (sdk) => {
seen.push(sdk)
if (sdk === initial) throw new ClientError("Transport")
return "ses-replacement"
},
})
expect(seen).toEqual([initial, replacement])
expect(reconnects).toBe(2)
expect(result).toEqual({ sdk: replacement, value: "ses-replacement" })
})
test("merges non-interactive argument and stdin input", () => {
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
})
test("applies a variant to a resumed session's model", () => {
expect(
pickRunModel(
undefined,
"high",
{ providerID: "session-provider", modelID: "session-model" },
{ providerID: "default-provider", modelID: "default-model" },
),
).toEqual({ providerID: "session-provider", modelID: "session-model" })
})
test("parses model variants from the model reference", () => {
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
)
expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({
providerID: "openrouter",
id: "openai/gpt-5",
variant: "high",
})
})
test("is registered in the preview CLI", async () => {
+161 -11
View File
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
type V2Event = EventSubscribeOutput
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
const location = { directory: "/work tree", workspaceID: "wrk_1" }
function ok<T>(data: T) {
return Promise.resolve(data)
@@ -18,8 +19,8 @@ function form(id: string, sessionID: string): FormInfo {
}
}
function formCreated(info: FormInfo): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } }
function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
}
function prompted(inputID: string): V2Event {
@@ -92,6 +93,64 @@ function executionFailed(message: string): V2Event {
}
}
function failedTool(inputID: string): V2Event[] {
return [
prompted(inputID),
{
id: "evt_failed_tool_input",
created: 1,
type: "session.tool.input.started",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
name: "shell",
},
},
{
id: "evt_failed_tool_called",
created: 2,
type: "session.tool.called",
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
input: { command: "printf partial && false" },
executed: true,
},
},
{
id: "evt_failed_tool_progress",
created: 3,
type: "session.tool.progress",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
},
},
{
id: "evt_failed_tool_terminal",
created: 4,
type: "session.tool.failed",
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
error: { type: "unknown", message: "tool failed" },
executed: true,
},
},
settled(),
]
}
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: {
@@ -100,6 +159,9 @@ async function run(input: {
attached?: boolean
format?: "default" | "json"
compatibility?: "v1"
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
}) {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
@@ -119,10 +181,18 @@ async function run(input: {
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.form.request, "list").mockImplementation(
() =>
ok({
location: { ...location, project: { id: "proj_1", directory: location.directory } },
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
}) as never,
)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
values.push(...input.turn(messageID))
@@ -133,6 +203,7 @@ async function run(input: {
await runNonInteractivePrompt({
client: sdk,
sessionID: "ses_1",
location,
message: "hello",
files: [],
thinking: false,
@@ -140,8 +211,8 @@ async function run(input: {
auto: false,
attached: input.attached ?? false,
compatibility: input.compatibility,
renderTool: () => Promise.resolve(),
renderToolError: () => Promise.resolve(),
renderTool: input.renderTool ?? (() => Promise.resolve()),
renderToolError: input.renderToolError ?? (() => Promise.resolve()),
})
return sdk
}
@@ -180,9 +251,20 @@ describe("runNonInteractivePrompt", () => {
// which must not leave the consume loop waiting forever.
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
})
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
const globalOptions = {
headers: {
"x-opencode-directory": "%2Fwork%20tree",
"x-opencode-workspace": "wrk_1",
},
}
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree", workspace: "wrk_1" },
})
expect(sdk.question.list).not.toHaveBeenCalled()
expect(sdk.question.reject).not.toHaveBeenCalled()
})
test("attach mode cancels only session-owned forms", async () => {
@@ -192,9 +274,12 @@ describe("runNonInteractivePrompt", () => {
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
})
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
expect(sdk.form.request.list).not.toHaveBeenCalled()
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
})
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
@@ -250,4 +335,69 @@ describe("runNonInteractivePrompt", () => {
expect(output).toEqual({ stdout: "", stderr: "" })
})
test("renders native failed tool output before the terminal error", async () => {
const rendered: SessionMessageAssistantTool[] = []
const failed: SessionMessageAssistantTool[] = []
await capture({
turn: failedTool,
renderTool: (part) => {
rendered.push(part)
return Promise.resolve()
},
renderToolError: (part) => {
failed.push(part)
return Promise.resolve()
},
})
expect(rendered).toMatchObject([
{
id: "call_failed_tool",
state: {
status: "completed",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
},
},
])
expect(failed).toMatchObject([
{
id: "call_failed_tool",
state: {
status: "error",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
error: { message: "tool failed" },
},
},
])
})
test("keeps failed tool partial output out of the explicit V1 JSON bridge shape", async () => {
const output = await capture({ compatibility: "v1", format: "json", turn: failedTool })
const events = output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
type: "tool_use",
part: {
type: "tool",
callID: "call_failed_tool",
tool: "shell",
state: {
status: "error",
input: { command: "printf partial && false" },
error: "tool failed",
},
},
})
expect(events[0].part.state.output).toBeUndefined()
expect(events[0].part.state.metadata.structured).toBeUndefined()
expect(events[0].part.state.metadata.content).toBeUndefined()
expect(output.stderr).toBe("")
})
})
+87
View File
@@ -0,0 +1,87 @@
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)
})
})
+41 -4
View File
@@ -542,13 +542,50 @@ export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.add"]>[0]
export type Endpoint11_1Input = {
readonly server: Endpoint11_1Request["params"]["server"]
readonly location?: Endpoint11_1Request["query"]["location"]
readonly config: Endpoint11_1Request["payload"]["config"]
}
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.add"]>>
export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
type Endpoint11_2Request = Parameters<RawClient["server.mcp"]["mcp.remove"]>[0]
export type Endpoint11_2Input = {
readonly server: Endpoint11_2Request["params"]["server"]
readonly location?: Endpoint11_2Request["query"]["location"]
}
export type Endpoint11_2Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.remove"]>>
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
type Endpoint11_3Request = Parameters<RawClient["server.mcp"]["mcp.connect"]>[0]
export type Endpoint11_3Input = {
readonly server: Endpoint11_3Request["params"]["server"]
readonly location?: Endpoint11_3Request["query"]["location"]
}
export type Endpoint11_3Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.connect"]>>
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
type Endpoint11_4Request = Parameters<RawClient["server.mcp"]["mcp.disconnect"]>[0]
export type Endpoint11_4Input = {
readonly server: Endpoint11_4Request["params"]["server"]
readonly location?: Endpoint11_4Request["query"]["location"]
}
export type Endpoint11_4Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.disconnect"]>>
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
type Endpoint11_5Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint11_5Input = { readonly location?: Endpoint11_5Request["query"]["location"] }
export type Endpoint11_5Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
export interface McpApi<E = never> {
readonly list: McpListOperation<E>
readonly add: McpAddOperation<E>
readonly remove: McpRemoveOperation<E>
readonly connect: McpConnectOperation<E>
readonly disconnect: McpDisconnectOperation<E>
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
}
+51 -4
View File
@@ -650,14 +650,61 @@ type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["loc
const Endpoint11_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_0Input) =>
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_1Input) =>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.add"]>[0]
type Endpoint11_1Input = {
readonly server: Endpoint11_1Request["params"]["server"]
readonly location?: Endpoint11_1Request["query"]["location"]
readonly config: Endpoint11_1Request["payload"]["config"]
}
const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_1Input) =>
raw["mcp.add"]({
params: { server: input["server"] },
query: { location: input["location"] },
payload: { config: input["config"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint11_2Request = Parameters<RawClient["server.mcp"]["mcp.remove"]>[0]
type Endpoint11_2Input = {
readonly server: Endpoint11_2Request["params"]["server"]
readonly location?: Endpoint11_2Request["query"]["location"]
}
const Endpoint11_2 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_2Input) =>
raw["mcp.remove"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint11_3Request = Parameters<RawClient["server.mcp"]["mcp.connect"]>[0]
type Endpoint11_3Input = {
readonly server: Endpoint11_3Request["params"]["server"]
readonly location?: Endpoint11_3Request["query"]["location"]
}
const Endpoint11_3 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_3Input) =>
raw["mcp.connect"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint11_4Request = Parameters<RawClient["server.mcp"]["mcp.disconnect"]>[0]
type Endpoint11_4Input = {
readonly server: Endpoint11_4Request["params"]["server"]
readonly location?: Endpoint11_4Request["query"]["location"]
}
const Endpoint11_4 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_4Input) =>
raw["mcp.disconnect"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint11_5Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
type Endpoint11_5Input = { readonly location?: Endpoint11_5Request["query"]["location"] }
const Endpoint11_5 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_5Input) =>
raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.mcp"]) => ({
list: Endpoint11_0(raw),
resource: { catalog: Endpoint11_1(raw) },
add: 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]
@@ -104,6 +104,14 @@ import type {
IntegrationCommandCancelOutput,
McpListInput,
McpListOutput,
McpAddInput,
McpAddOutput,
McpRemoveInput,
McpRemoveOutput,
McpConnectInput,
McpConnectOutput,
McpDisconnectInput,
McpDisconnectOutput,
McpResourceCatalogInput,
McpResourceCatalogOutput,
CredentialUpdateInput,
@@ -1044,6 +1052,55 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
add: (input: McpAddInput, requestOptions?: RequestOptions) =>
request<McpAddOutput>(
{
method: "PUT",
path: `/api/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
body: { config: input["config"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: McpRemoveInput, requestOptions?: RequestOptions) =>
request<McpRemoveOutput>(
{
method: "DELETE",
path: `/api/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
connect: (input: McpConnectInput, requestOptions?: RequestOptions) =>
request<McpConnectOutput>(
{
method: "POST",
path: `/api/mcp/${encodeURIComponent(input.server)}/connect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
disconnect: (input: McpDisconnectInput, requestOptions?: RequestOptions) =>
request<McpDisconnectOutput>(
{
method: "POST",
path: `/api/mcp/${encodeURIComponent(input.server)}/disconnect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
resource: {
catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
request<McpResourceCatalogOutput>(
@@ -2482,6 +2482,14 @@ export type ProviderNotFoundError = {
export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError"
export type McpServerNotFoundError = {
readonly _tag: "McpServerNotFoundError"
readonly server: string
readonly message: string
}
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
@@ -3461,6 +3469,84 @@ export type McpListOutput = {
data: Array<McpServer>
}
export type McpAddInput = {
readonly server: { readonly server: string }["server"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly config: {
readonly config:
| {
readonly type: "local"
readonly command: ReadonlyArray<string>
readonly cwd?: string | undefined
readonly environment?: { readonly [x: string]: string } | undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
}
| {
readonly type: "remote"
readonly url: string
readonly headers?: { readonly [x: string]: string } | undefined
readonly oauth?:
| {
readonly client_id?: string | undefined
readonly client_secret?: string | undefined
readonly scope?: string | undefined
readonly callback_port?: number | undefined
readonly redirect_uri?: string | undefined
}
| false
| undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
}
}["config"]
}
export type McpAddOutput = void
export type McpRemoveInput = {
readonly server: { readonly server: string }["server"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpRemoveOutput = void
export type McpConnectInput = {
readonly server: { readonly server: string }["server"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpConnectOutput = void
export type McpDisconnectInput = {
readonly server: { readonly server: string }["server"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpDisconnectOutput = void
export type McpResourceCatalogInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+97 -136
View File
@@ -1,41 +1,30 @@
# @opencode-ai/codemode
This is our take on code mode. Programs are written in a lightweight, JavaScript-like DSL and run in the package's
own interpreter. They never execute as actual JavaScript, so there is no runtime to escape into. The interpreter
itself can reach nothing; every effect a program has goes through a tool you explicitly supplied. The tradeoff is a
bounded language rather than full JavaScript: the [interpreter support checklist](./interpreter-support.md) documents
exactly what is supported.
This is our take on code mode: a lightweight, pure interpreter for a JavaScript-like language built around calling
tools. It supports familiar JavaScript syntax with a few key differences and limitations. See the
[interpreter support checklist](./interpreter-support.md) for more details.
[Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes
generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application
runs, no sandbox required.
Rather than trying to sandbox arbitrary JavaScript, CodeMode only runs the language features we implement. Programs
cannot directly access the network, filesystem, processes, or application APIs. They can interact with the outside
world only through tools provided by the host, which can also limit execution time, tool calls, output size, and data.
The idea of code mode was originally introduced by Cloudflare. See
[their post](https://blog.cloudflare.com/code-mode/) to learn more about the concept and their isolate-based approach.
## How it differs from JavaScript
The deliberate differences:
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
becomes `null`.
- **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
Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location. Current gaps are tracked in the
[interpreter support checklist](./interpreter-support.md).
## Quick Start
The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect`
and should depend on `effect` themselves. Define tools with Effect Schema, then expose them to programs through
`tools`:
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
@@ -48,161 +37,133 @@ const lookupOrder = Tool.make({
})
const runtime = CodeMode.make({
tools: {
orders: {
lookup: lookupOrder,
},
},
tools: { orders: { lookup: lookupOrder } },
})
const result =
yield *
const result = await Effect.runPromise(
runtime.execute(`
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
`)
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
`),
)
```
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics
rather than failing the Effect; host interruption remains interruption.
`result` is always a [`CodeMode.Result`](#results).
## API
### `Tool.make`
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input
is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON
Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise<unknown>`.
Descriptions and schemas are model-visible contract; keep authorization in `run`.
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only
shape the model-visible signature. Without `output`, the signature uses `Promise<unknown>`.
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.
Descriptions and schemas are model-visible contracts. Authorization belongs in `run`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as
`tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make`
`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:
`CodeMode.execute({ ...options, code })` runs once. `CodeMode.make(options)` creates a reusable runtime:
```ts
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // CodeMode.Result
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional
`onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are
Effect-returning and must not fail.
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
### OpenAPI tools
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into namespaced tools - one tool per operation, using dotted
`operationId` segments as namespaces:
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
create namespaces:
```ts
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
```
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
model-visible; generated tools require `HttpClient.HttpClient` in the environment. `readOnly` properties are omitted
from request signatures and `writeOnly` properties from response signatures. These JSON Schemas are model-facing, not
runtime filters: nested value bodies and server responses pass through unchanged. See the option docstrings in
`src/openapi/types.ts` for full semantics.
The synchronous result is `{ tools, skipped }`. Operations with unsupported parameter encodings, request bodies
without JSON content, WebSocket or SSE semantics, or binary responses are reported in `skipped`.
## Outputs
Authentication is resolved by the host and never shown to the model. Generated tools require `HttpClient.HttpClient`.
Request signatures omit `readOnly` properties; response signatures omit `writeOnly` properties. These JSON Schemas
shape model-visible signatures but do not filter runtime values: nested JSON body properties and decoded server
responses pass through unchanged. See `src/openapi/types.ts` for option details.
Every execution returns a `CodeMode.Result`:
## Results
Every execution returns:
```ts
type Result = Success | Failure
interface Success {
readonly ok: true
readonly value: CodeMode.DataValue
readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
interface Failure {
readonly ok: false
readonly error: CodeMode.Diagnostic
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
type Result =
| {
readonly ok: true
readonly value: CodeMode.DataValue
readonly warnings?: ReadonlyArray<CodeMode.Diagnostic>
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
| {
readonly ok: false
readonly error: CodeMode.Diagnostic
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
```
`value` is JSON-safe data. `warnings` are non-fatal diagnostics alongside a valid value (un-awaited rejections,
timeout cleanup after the return). `logs` holds program console output, `truncated` marks any output-budget cut, and
`toolCalls` lists admitted calls in order - retained on failure for auditing.
`value` is JSON-safe. `warnings` are non-fatal diagnostics, `logs` contain program console output, and `truncated`
indicates that retained output was cut by `maxOutputBytes`. `toolCalls` retains admitted calls in order, including after
failure.
Failure `error` and success `warnings` share one diagnostic vocabulary:
Diagnostic kinds:
| Kind | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`; as a warning, background work was interrupted after the program returned. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
| Kind | Meaning |
| ----------------------- | ---------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | The program referenced an unavailable tool. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract. |
| `ToolCallLimitExceeded` | The program exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution timed out; as a warning, background work was interrupted after the program returned. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning only: additional warnings were omitted by `maxOutputBytes`. |
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` is the explicit channel
for a model-visible refusal; its optional cause never crosses the boundary.
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` explicitly exposes a
safe refusal to the model; its optional cause remains private.
## Discovery
The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with
`discovery: { catalogBudget }`): every namespace is always listed with its tool count, signatures are selected
round-robin so every namespace gets representation, and the instructions state whether the list is complete or
partial. Programs also get a global `search(...)` built-in - always available, advertised when the list is partial:
synchronous, deterministic field-weighted substring matching that returns directly callable paths with full
signatures, supports namespace scoping and pagination, and treats an empty query as browsing and an exact path as
lookup. Search counts as an admitted tool call.
Generated instructions contain a tool catalog with a default budget of 2,000 estimated tokens. Configure it with
`discovery: { catalogBudget }`. Every namespace remains visible, and the instructions say whether the catalog is
complete or partial.
The synchronous `search(...)` built-in is always available and advertised when the catalog is partial. It supports
exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full
signatures. Search counts toward `maxToolCalls`.
## Execution Limits
| Limit | Default | Bounds |
| ---------------- | -------------------: | ---------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
| Limit | Default | Controls |
| ---------------- | --------- | ------------------------------- |
| `timeoutMs` | unlimited | Total execution time. |
| `maxToolCalls` | unlimited | Admitted tool calls. |
| `maxOutputBytes` | unlimited | Retained result value and logs. |
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.
Execution limits have no default values.
## Boundaries and Non-Goals
The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy.
CodeMode owns interpretation, schema and plain-data boundaries, resource limits, diagnostics, and discovery. A program
can only exercise authority already present in the supplied tools - do not expose a broad tool and expect the prompt
to restrict it.
Non-goals: permission prompts and approval workflows, durable pause/resume or replay, exactly-once side effects,
application authorization policy, sandboxing arbitrary JavaScript, and compatibility with the full language or npm
ecosystem. Applications that need approval or durable consequences should model those above CodeMode and expose only
the currently authorized tools.
## Testing
From the package directory:
```sh
bun test
bun run typecheck
```
Invalid limit configuration throws `RangeError`. Warnings receive a separate budget equal to `maxOutputBytes`.
Truncation does not fail execution; an oversized value becomes a string with an in-band marker. Timeouts interrupt
tool calls and busy loops, while a result returned before cleanup times out remains successful with a
`TimeoutExceeded` warning. Tool-call concurrency is unrestricted. Boundary data is limited to 32 nested levels.
+11 -12
View File
@@ -91,8 +91,8 @@ ultimate source of truth.
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
- [ ] Stop automatically awaiting promise-returning string replacers; match JavaScript's synchronous callback-result
coercion.
- [x] Promise-returning string replacers are coerced synchronously to `"[object Promise]"`, like JavaScript; they are
not automatically awaited.
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so
ignoring it matches JS arrow-function semantics exactly.
- [ ] `this` in non-arrow CodeMode functions and callbacks.
@@ -196,11 +196,11 @@ ultimate source of truth.
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [ ] `Array.prototype.toSpliced`.
- [ ] Canonical array/string index parsing: a key such as `"01"` must remain an ordinary property key rather than
aliasing index `1`.
- [ ] `Array.prototype.sort` and `toSorted` must preserve trailing holes; they currently turn holes into own
`undefined` elements.
- [x] `Array.prototype.toSpliced`.
- [x] Canonical array/string index parsing: keys such as `"01"` remain non-index properties rather than aliasing index
`1`; arbitrary array-property assignment remains unsupported.
- [x] `Array.prototype.sort` preserves trailing holes, while `toSorted` densifies holes into `undefined` elements,
like JavaScript.
## Strings
@@ -266,11 +266,10 @@ ultimate source of truth.
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
- [ ] Date setters.
- [ ] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [ ] Native one-argument Date coercion; unsupported boolean/object inputs currently become invalid dates instead of
being coerced.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [ ] Native Date loose-equality and default primitive-coercion semantics.
- [ ] Native `RangeError` branding for invalid `toISOString()` calls.
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
## Regular expressions
@@ -279,7 +278,7 @@ ultimate source of truth.
- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`.
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [ ] Writable `lastIndex`.
- [x] Writable `lastIndex`.
- [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags.
- [ ] `RegExp.escape`.
+29 -9
View File
@@ -428,16 +428,14 @@ const invokeStringReplacer = <R>(
let end = 0
for (const match of matches) {
const replacement = yield* apply(match.args)
const resolved =
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise
? yield* runner.settlePromise(replacement)
: replacement
// Error values are branded plain objects; boundedData would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
errorBrandName(resolved)
? coerceToString(resolved)
: coerceToString(boundedData(resolved, `String.${name} replacer result`)),
replacement instanceof CodeModePromise
? "[object Promise]"
: errorBrandName(replacement)
? coerceToString(replacement)
: coerceToString(boundedData(replacement, `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
@@ -664,11 +662,20 @@ const invokeArrayMethod = <R>(
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
case "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) => {
target.splice(0, target.length, ...sorted)
sorted.slice(0, itemCount).forEach((item, index) => {
target[index] = item
})
Array.from({ length: holeCount }, (_, index) => itemCount + index).forEach((index) => {
Reflect.deleteProperty(target, index)
})
return target
})
}
case "toSorted":
return sortArray(runner, target, args[0], "Array.toSorted", node)
case "toReversed":
@@ -707,6 +714,19 @@ const invokeArrayMethod = <R>(
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
}
case "toSpliced": {
if (args.length === 0) return Effect.succeed([...target])
const start = optNumber(args[0], "start") ?? 0
if (args.length === 1) {
const copied = [...target]
copied.splice(start)
return Effect.succeed(copied)
}
const deleteCount = optNumber(args[1], "delete count") ?? 0
const copied = [...target]
copied.splice(start, deleteCount, ...args.slice(2))
return Effect.succeed(copied)
}
case "fill": {
rejectCircularInsertion(target, args[0], "Array.fill result", node)
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SafeObject } from "../tool-runtime.js"
import type { CodeModePromise, CodeModeURL } from "../values.js"
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
export type SourcePosition = {
line: number
@@ -35,7 +35,7 @@ export type StatementResult =
| { kind: "continue" }
export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeURL
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: string | number
}
+67 -31
View File
@@ -96,6 +96,15 @@ const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
URL: urlStatics,
}
const MAX_ARRAY_LENGTH = 4_294_967_295
const parseArrayIndex = (key: string | number): number | undefined => {
const property = String(key)
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
const index = Number(property)
return index < MAX_ARRAY_LENGTH ? index : undefined
}
const calleeDescription = (callee: AstNode): string => {
if (callee.type === "Identifier") return getString(callee, "name")
if (callee.type === "MemberExpression") {
@@ -1095,7 +1104,7 @@ export class Interpreter<R> {
const args = yield* self.evaluateCallArguments(argNodes)
switch (name) {
case "Date":
return self.constructDate(args)
return yield* self.constructDate(args, node)
case "RegExp":
return self.constructRegExp(args, node)
case "Map":
@@ -1133,17 +1142,37 @@ export class Interpreter<R> {
)
}
private constructDate(args: Array<unknown>): CodeModeDate {
if (args.length === 0) return new CodeModeDate(Date.now())
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> {
if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now()))
if (args.length === 1) {
const arg = args[0]
if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time)
if (typeof arg === "number") return new CodeModeDate(new Date(arg).getTime())
if (typeof arg === "string") return new CodeModeDate(Date.parse(arg))
return new CodeModeDate(Number.NaN)
if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time))
return Effect.map(this.toDatePrimitive(arg, node), (value) =>
typeof value === "string"
? new CodeModeDate(Date.parse(value))
: new CodeModeDate(new Date(coerceToNumber(value)).getTime()),
)
}
const parts = args.map((arg) => coerceToNumber(arg))
return new CodeModeDate(new Date(...(parts as [number, number])).getTime())
return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime()))
}
private toDatePrimitive(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
if (value === null || (typeof value !== "object" && typeof value !== "function")) return Effect.succeed(value)
const object = value as Record<string, unknown>
const self = this
return Effect.gen(function* () {
if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
const result = yield* self.runner.invokeCallable(object.valueOf, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
if (!Object.hasOwn(object, "toString")) return coerceToString(value)
if (typeofValue(object.toString) === "function") {
const result = yield* self.runner.invokeCallable(object.toString, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
})
}
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
@@ -1896,8 +1925,8 @@ export class Interpreter<R> {
if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length)
if (typeof key === "number") return new ComputedValue(objectValue[key])
if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)])
const index = parseArrayIndex(key)
if (index !== undefined) return new ComputedValue(objectValue[index])
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
@@ -1929,6 +1958,7 @@ export class Interpreter<R> {
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeRegExp) {
if (key === "lastIndex") return { target: objectValue, key }
if (typeof key === "string" && regexpProperties.has(key)) {
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
}
@@ -1991,18 +2021,14 @@ export class Interpreter<R> {
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
if (
key !== "length" &&
!(typeof key === "string" && arrayMethods.has(key)) &&
typeof key !== "number" &&
!/^\d+$/.test(key)
) {
const index = parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
}
return new ComputedValue(undefined)
}
return { target: objectValue, key }
return { target: objectValue, key: index ?? key }
}
return { target: objectValue as SafeObject, key }
@@ -2023,11 +2049,11 @@ export class Interpreter<R> {
)
return reference
if (Array.isArray(reference.target)) {
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
return new IntrinsicReference(reference.target, reference.key)
}
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return reference.target[reference.key]
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
}
@@ -2058,6 +2084,9 @@ export class Interpreter<R> {
) {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
}
if (reference.target instanceof CodeModeRegExp) {
return Reflect.deleteProperty(reference.target.regex, reference.key)
}
return Reflect.deleteProperty(reference.target, reference.key)
})
}
@@ -2089,30 +2118,33 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
}
}
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
const current =
reference.target instanceof CodeModeURL
? (reference.target.url as unknown as Record<string, unknown>)[key]
: (reference.target as Record<PropertyKey, unknown>)[key]
const { write, next, result } = yield* compute(current)
const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
if (write) self.assignToReference(reference, key, next, node)
return result
})
}
private readReferenceValue(reference: MemberReference, key: number | string): unknown {
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[key]
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return (reference.target as Record<PropertyKey, unknown>)[key]
}
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) {
const target = reference.target
const index = key as number
if (!Number.isInteger(index) || index < 0) {
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
throw new InterpreterRuntimeError(
"Array assignment index must be a non-negative integer.",
"Array assignment index must be a valid array index.",
node,
"InvalidDataValue",
)
}
rejectCircularInsertion(target, next, "Array assignment result", node)
target[index] = next
target[key] = next
return
}
if (reference.target instanceof CodeModeURL) {
@@ -2129,6 +2161,10 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
}
}
if (reference.target instanceof CodeModeRegExp) {
reference.target.lastIndex = next
return
}
const target = reference.target as SafeObject
const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node)
@@ -29,6 +29,7 @@ export const arrayMethods = new Set([
"shift",
"unshift",
"splice",
"toSpliced",
"fill",
"copyWithin",
"keys",
+6 -1
View File
@@ -4,6 +4,8 @@ export const dateMethods = new Set([
"toISOString",
"toJSON",
"toString",
"toUTCString",
"toGMTString",
"getFullYear",
"getMonth",
"getDate",
@@ -45,12 +47,15 @@ export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNod
case "valueOf":
return value.time
case "toISOString":
if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node)
if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node).as("RangeError")
return hosted.toISOString()
case "toJSON":
return Number.isFinite(value.time) ? hosted.toISOString() : null
case "toString":
return coerceToString(value)
case "toUTCString":
case "toGMTString":
return hosted.toUTCString()
case "getFullYear":
return hosted.getFullYear()
case "getMonth":
+18 -3
View File
@@ -59,9 +59,18 @@ export const invokeRegExpMethod = (
): unknown => {
switch (name) {
case "test":
return value.regex.test(coerceToString(args[0]))
case "exec": {
const matched = value.regex.exec(coerceToString(args[0]))
const input = coerceToString(args[0])
const lastIndex = value.lastIndex
const stateful = value.regex.global || value.regex.sticky
value.regex.lastIndex = toLength(lastIndex)
if (name === "test") {
const matched = value.regex.test(input)
if (!stateful) value.lastIndex = lastIndex
return matched
}
const matched = value.regex.exec(input)
if (!stateful) value.lastIndex = lastIndex
return matched === null ? null : matchToValue(matched)
}
case "toString":
@@ -70,7 +79,13 @@ export const invokeRegExpMethod = (
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
}
}
const toLength = (value: unknown): number => {
const number = coerceToNumber(value)
if (Number.isNaN(number) || number <= 0) return 0
return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { CodeModeRegExp } from "../values.js"
import { coerceToString } from "./value.js"
import { coerceToNumber, coerceToString } from "./value.js"
+8
View File
@@ -13,6 +13,14 @@ export class CodeModeRegExp {
constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags)
}
get lastIndex(): unknown {
return Reflect.get(this.regex, "lastIndex")
}
set lastIndex(value: unknown) {
Reflect.set(this.regex, "lastIndex", value)
}
}
export class CodeModeMap {
@@ -29,6 +29,11 @@
* - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.1_T1.js
* - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.2_T1.js
* - test/built-ins/Array/prototype/splice/called_with_one_argument.js
* - test/built-ins/Array/prototype/toSpliced/holes-not-preserved.js
* - test/built-ins/Array/prototype/toSpliced/immutable.js
* - test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-undefineds.js
* - test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-missing.js
* - test/built-ins/Array/prototype/toSpliced/start-undefined-and-deleteCount-missing.js
* - test/built-ins/Array/prototype/fill/fill-values.js
* - test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js
* - test/built-ins/Array/prototype/fill/return-this.js
@@ -59,6 +64,8 @@
* Copyright 2015 Microsoft Corporation. All rights reserved.
* Copyright 2016 The V8 project authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
* The toSpliced hole case omits the source test's inherited Array.prototype element because CodeMode does not expose
* prototype mutation; it retains the source test's hole-densification assertions.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
@@ -227,6 +234,31 @@ const cases = [
code: `const input = ["first", "second", "third"]; const removed = input.splice(1); return [input, removed]`,
expected: [["first"], ["second", "third"]],
},
{
path: "test/built-ins/Array/prototype/toSpliced/immutable.js",
code: `const input = [2, 0, 1]; const inserted = input.toSpliced(0, 0, -1); const replaced = input.toSpliced(0, 1, -1); return [input, inserted, replaced, inserted !== input, replaced !== input]`,
expected: [[2, 0, 1], [-1, 2, 0, 1], [-1, 0, 1], true, true],
},
{
path: "test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-missing.js",
code: `const input = ["first", "second", "third"]; const result = input.toSpliced(); return [result, result !== input]`,
expected: [["first", "second", "third"], true],
},
{
path: "test/built-ins/Array/prototype/toSpliced/start-undefined-and-deleteCount-missing.js",
code: `return ["first", "second", "third"].toSpliced(undefined)`,
expected: [],
},
{
path: "test/built-ins/Array/prototype/toSpliced/start-and-deleteCount-undefineds.js",
code: `const input = ["first", "second", "third"]; const result = input.toSpliced(undefined, undefined); return [result, result !== input]`,
expected: [["first", "second", "third"], true],
},
{
path: "test/built-ins/Array/prototype/toSpliced/holes-not-preserved.js",
code: `const input = [0, , 2, , 4]; const result = input.toSpliced(0, 0, -1); return [result, 2 in result, 4 in result]`,
expected: [[-1, 0, null, 2, null, 4], true, true],
},
{
path: "test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js",
code: `const input = [0, 0, 0, 0, 0]; input.fill(8, -3, 4); const sparse = []; sparse[4] = 0; sparse.fill(8, 1, 3); return [[0, 0, 0].fill(8, 1, 2), input, [0, 0, 0, 0, 0].fill(8, -2, -1), [0, 0, 0, 0, 0].fill(8, -1, -3), [0 in sparse, sparse[1], sparse[2], 3 in sparse, sparse[4]]]`,
+51 -4
View File
@@ -131,10 +131,6 @@ describe("constructors callable without new, like JS", () => {
expect((await error(`try { Array(-1) } catch (e) { throw Error(e.name) }`)).message).toContain("RangeError")
})
test("sort densifies trailing holes into undefined (documented divergence)", async () => {
expect(await value(`return Array(2).sort().map(() => 1)`)).toEqual([1, 1])
})
test("returned sparse arrays normalize holes to null at the host boundary", async () => {
expect(await value(`return Array(3)`)).toEqual([null, null, null])
})
@@ -153,6 +149,57 @@ describe("constructors callable without new, like JS", () => {
})
describe("sort accepts the unified callback set", () => {
test("sort preserves trailing holes while toSorted densifies them", async () => {
expect(
await value(`
const defaultSorted = [2, , 1]
const compared = [2, , 1]
const copied = defaultSorted.toSorted()
defaultSorted.sort()
compared.sort((a, b) => a - b)
return [
Object.hasOwn(defaultSorted, 2),
Object.hasOwn(compared, 2),
Object.hasOwn(copied, 2),
]
`),
).toEqual([false, false, true])
expect(await value(`const values = [2, undefined, 1]; values.sort(); return Object.hasOwn(values, 2)`)).toBe(true)
})
test("sort writes its snapshot without discarding comparator length mutations", async () => {
expect(
await value(`
const values = [3, 2, 1]
let first = true
values.sort((a, b) => {
if (first) {
first = false
values.push("kept")
}
return a - b
})
return values
`),
).toEqual([1, 2, 3, "kept"])
expect(
await value(`
const values = [3, , 1, , 2]
let first = true
values.sort((a, b) => {
if (first) {
first = false
values.splice(0)
}
return a - b
})
return { values, owns: values.map((_, index) => Object.hasOwn(values, index)) }
`),
).toEqual({ values: [1, 2, 3], owns: [true, true, true] })
})
test("sort and toSorted take built-in comparators", async () => {
expect(await value(`return [0, 1, 0].sort(Boolean)`)).toEqual([0, 0, 1])
expect(await value(`return [0, 1, 0].toSorted(Boolean)`)).toEqual([0, 0, 1])
+42 -1
View File
@@ -42,6 +42,15 @@ describe("H2: string property access reads as undefined (not a throw)", () => {
test("unknown property on a number is undefined", async () => {
expect(await value(`return (5).foo ?? "n"`)).toBe("n")
})
test("only canonical string index keys access characters", async () => {
expect(
await value(`
const text = "abc"
return [text[1], text["1"], text["01"], text["1.0"], text[-0], text["-0"]]
`),
).toEqual(["b", "b", null, null, "a", null])
})
})
describe("H3: array property access reads as undefined (not a throw)", () => {
@@ -55,13 +64,45 @@ describe("H3: array property access reads as undefined (not a throw)", () => {
})
test("unknown property reads stay undefined for methods CodeMode does not implement", async () => {
expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true)
expect(await value(`return [1,2,3].unknownMethod === undefined`)).toBe(true)
})
test("array indexing still works", async () => {
expect(await value(`return [1,2,3][9] === undefined`)).toBe(true)
expect(await value(`return [1,2,3][9]`)).toBeNull()
})
test("only canonical array index keys access elements", async () => {
expect(
await value(`
const values = ["a", "b"]
return [values[1], values["1"], values["01"], values["1.0"], values[-0], values["-0"]]
`),
).toEqual(["b", "b", null, null, "a", null])
})
test("noncanonical keys cannot mutate or delete an aliased element", async () => {
expect(
await value(`
const values = ["a", "b"]
let writes = 0
try { values["01"] = ++writes } catch {}
const removed = delete values["01"]
return [writes, removed, values]
`),
).toEqual([0, true, ["a", "b"]])
})
test("the maximum array length is not accepted as an array index", async () => {
expect(
await value(`
const values = []
let writes = 0
try { values["4294967295"] = ++writes } catch {}
return [writes, values.length]
`),
).toEqual([0, 0])
})
})
describe("H6: object spread of null/undefined is a no-op", () => {
+132 -7
View File
@@ -1,3 +1,19 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/built-ins/Date/value-to-primitive-result-non-string-prim.js
* - test/built-ins/Date/value-to-primitive-result-string.js
* - test/built-ins/Date/prototype/toUTCString/format.js
* - test/built-ins/Date/prototype/toUTCString/invalid-date.js
* - test/built-ins/RegExp/prototype/exec/S15.10.6.2_A4_T8.js
*
* CodeMode does not support Symbol.toPrimitive, so the Date-constructor cases exercise the same primitive-result
* handling through supported own valueOf and toString functions.
*
* Copyright (C) 2016 the V8 project authors. All rights reserved.
* Copyright (C) 2017 the V8 project authors. All rights reserved.
* Copyright 2009 the Sputnik authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
@@ -55,6 +71,52 @@ describe("Date", () => {
expect(await value(`return Date.parse("2024-01-02T03:04:05.000Z")`)).toBe(1704164645000)
})
test("one-argument construction coerces supported values like JavaScript", async () => {
expect(
await value(`return [new Date(true).getTime(), new Date(false).getTime(), new Date(null).getTime()]`),
).toEqual([1, 0, 0])
expect(await value(`return Number.isNaN(new Date(undefined).getTime())`)).toBe(true)
expect(await value(`return Number.isNaN(new Date([]).getTime())`)).toBe(true)
expect(await value(`return new Date(["1970-01-01T00:00:00.000Z"]).getTime()`)).toBe(0)
expect(await value(`return Number.isNaN(new Date({}).getTime())`)).toBe(true)
})
test("one-argument construction uses valueOf then toString for objects", async () => {
expect(
await value(`
const calls = []
const number = { valueOf: () => 8 }
const text = {
valueOf: () => { calls.push("valueOf"); return {} },
toString: () => { calls.push("toString"); return "2016-06-05T18:40:00.000Z" },
}
return [new Date(number).getTime(), new Date(text).getTime(), calls]
`),
).toEqual([8, 1465152000000, ["valueOf", "toString"]])
expect(
await value(`
const values = [
{ valueOf: () => undefined },
{ valueOf: () => true },
{ valueOf: () => false },
{ valueOf: () => null },
]
return values.map((item) => new Date(item).getTime())
`),
).toEqual([null, 1, 0, 0])
expect(
await value(`
try {
new Date({ valueOf: () => ({}), toString: () => ({}) })
} catch (error) {
return error.name
}
`),
).toBe("TypeError")
})
test("date arithmetic and comparison use the time value", async () => {
expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000)
expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true)
@@ -74,9 +136,9 @@ describe("Date", () => {
expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
})
test("toISOString on an invalid date is a catchable error", async () => {
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe(
"caught",
test("toISOString on an invalid date throws RangeError", async () => {
expect(await value(`try { new Date("garbage").toISOString() } catch (error) { return error.name }`)).toBe(
"RangeError",
)
})
@@ -118,6 +180,25 @@ describe("Date", () => {
expect(await value(`return typeof new Date(0)`)).toBe("object")
expect(await value(`return new Date(0).nope === undefined`)).toBe(true)
})
test("toUTCString and toGMTString use the native UTC format", async () => {
expect(
await value(`
const date = new Date(0)
return [
date.toUTCString(),
date.toGMTString(),
new Date(NaN).toUTCString(),
new Date("0020-01-01T00:00:00Z").toUTCString(),
]
`),
).toEqual([
"Thu, 01 Jan 1970 00:00:00 GMT",
"Thu, 01 Jan 1970 00:00:00 GMT",
"Invalid Date",
"Wed, 01 Jan 0020 00:00:00 GMT",
])
})
})
describe("RegExp", () => {
@@ -154,6 +235,51 @@ describe("RegExp", () => {
).toEqual(["1", "22"])
})
test("lastIndex is writable and exec coerces its stored value", async () => {
expect(
await value(`
const pattern = /(?:ab|cd)\\d?/g
pattern.lastIndex = "12"
const stored = [pattern.lastIndex, typeof pattern.lastIndex]
const match = pattern.exec("aacd2233ab12nm444ab42")
pattern.lastIndex = 0
return [stored, match[0], match.index, pattern.lastIndex, delete pattern.lastIndex]
`),
).toEqual([["12", "string"], "ab4", 17, 0, false])
})
test("exec coerces CodeMode data objects assigned to lastIndex", async () => {
expect(
await value(`
const pattern = /a/g
pattern.lastIndex = {}
const stored = pattern.lastIndex
const match = pattern.exec("ba")
pattern.lastIndex = 10
const missed = pattern.exec("a")
return [stored, match.index, pattern.lastIndex, missed]
`),
).toEqual([{}, 1, 0, null])
})
test("non-global exec and test coerce and preserve lastIndex", async () => {
expect(
await value(`
const execPattern = /a/
const execIndex = {}
execPattern.lastIndex = execIndex
const match = execPattern.exec("ba")
const testPattern = /a/
const testIndex = {}
testPattern.lastIndex = testIndex
const matched = testPattern.test("ba")
return [match.index, execPattern.lastIndex === execIndex, matched, testPattern.lastIndex === testIndex]
`),
).toEqual([1, true, true, true])
})
test("an unmatched string pattern returns null", async () => {
expect(await value(`return "abc".match(/\\d/)`)).toBeNull()
})
@@ -198,7 +324,7 @@ describe("RegExp", () => {
).toBe("7null[object Object]")
})
test("function replacers can await effectful tool calls", async () => {
test("promise-returning string replacers are coerced synchronously", async () => {
const decorate = Tool.make({
description: "Decorate a string",
input: Schema.String,
@@ -211,7 +337,7 @@ describe("RegExp", () => {
code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`,
}),
)
expect(result.ok && result.value).toBe("a[1]b[22]")
expect(result.ok && result.value).toBe("a[object Promise]b[object Promise]")
const missingAwait = await Effect.runPromise(
CodeMode.execute({
@@ -219,8 +345,7 @@ describe("RegExp", () => {
code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`,
}),
)
expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue")
expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise")
expect(missingAwait.ok && missingAwait.value).toBe("a[object Promise]")
})
test("replaceAll without the g flag is a catchable error", async () => {
-2
View File
@@ -9,7 +9,6 @@ import "./app.css"
import { LanguageProvider } from "~/context/language"
import { I18nProvider, useI18n } from "~/context/i18n"
import { strip } from "~/lib/language"
import { DesktopPromo } from "~/component/desktop-promo"
function AppMeta() {
const i18n = useI18n()
@@ -34,7 +33,6 @@ export default function App() {
<MetaProvider>
<AppMeta />
<Suspense>{props.children}</Suspense>
<DesktopPromo />
</MetaProvider>
</I18nProvider>
</LanguageProvider>
+12 -48
View File
@@ -1,55 +1,19 @@
export * as ConfigMCP from "./mcp"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
import { Mcp } from "@opencode-ai/schema/mcp"
export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: Timeout.pipe(Schema.optional),
}) {}
export class OAuth extends Schema.Class<OAuth>("ConfigV2.MCP.OAuth")({
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
}) {}
export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: Timeout.pipe(Schema.optional),
}) {}
export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type"))
// The MCP server config is a public wire contract (used by the mcp.add route), so it lives in
// @opencode-ai/schema and is re-exported here.
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
export const Local = Mcp.LocalConfig
export type Local = Mcp.LocalConfig
export const OAuth = Mcp.OAuthConfig
export type OAuth = Mcp.OAuthConfig
export const Remote = Mcp.RemoteConfig
export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
timeout: Timeout.pipe(Schema.optional),
+8 -5
View File
@@ -365,11 +365,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
}),
)
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
yield* tools
.registerBatch(
registrations.map((registration) => ({
tools: { [registration.name]: registration.tool },
...(registration.options === undefined ? {} : { options: registration.options }),
})),
)
.pipe(Effect.orDie)
return { dispose: Effect.void }
}),
hook: (name, callback) => {
+104 -67
View File
@@ -1,7 +1,7 @@
export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai"
import { Context, Effect, Layer, Scope } from "effect"
import { Context, Effect, Layer, Scope, Semaphore } from "effect"
import type { AgentV2 } from "../agent"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
@@ -45,6 +45,13 @@ export interface Interface {
tools: Readonly<Record<string, AnyTool>>,
options?: Tools.RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, AnyTool>>
readonly options?: Tools.RegisterOptions
}>,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
export interface Materialization {
@@ -107,6 +114,7 @@ const registryLayer = Layer.effect(
readonly codemode: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const registrationLock = Semaphore.makeUnsafe(1)
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
@@ -192,82 +200,111 @@ const registryLayer = Layer.effect(
}
})
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
const entries = registrationEntries(tools, options?.namespace)
if (entries.length === 0) return
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
)
yield* Effect.uninterruptible(
const registerBatch: Interface["registerBatch"] = Effect.fn("ToolRegistry.registerBatch")(
function* (registrations) {
const planned = yield* Effect.forEach(registrations, ({ tools, options }) =>
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{
token,
registration: {
tool: entry.tool,
name: entry.name,
namespace: entry.namespace,
codemode,
},
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const entry of entries) {
const registrations =
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
}
}),
)
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
const entries = registrationEntries(tools, options?.namespace)
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
)
return { entries, codemode }
}),
)
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
const direct = new Map<string, Registration>()
const codemode = new Map<string, Registration>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
if (whollyDisabled(permission(registration.tool, name), rules)) continue
if (registration.codemode) codemode.set(name, registration)
else direct.set(name, registration)
}
const execute =
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
...(execute ? [definition("execute", execute)] : []),
],
settle: (input) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name)
if (registration) return settleTool(input, registration.tool)
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
if (planned.every((plan) => plan.entries.length === 0)) return
yield* Effect.uninterruptible(
registrationLock.withPermit(
Effect.gen(function* () {
const token = {}
for (const { entries, codemode } of planned)
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{
token,
registration: {
tool: entry.tool,
name: entry.name,
namespace: entry.namespace,
codemode,
},
},
])
yield* Effect.addFinalizer(() =>
registrationLock.withPermit(
Effect.sync(() => {
for (const { entries } of planned)
for (const entry of entries) {
const registrations =
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
}
}),
),
)
}),
),
)
},
)
return Service.of({
register: Effect.fn("ToolRegistry.register")((tools, options) =>
registerBatch([
{
tools,
...(options === undefined ? {} : { options }),
},
}
}),
]),
),
registerBatch,
materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
registrationLock.withPermit(
Effect.sync(() => {
const direct = new Map<string, Registration>()
const codemode = new Map<string, Registration>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
if (whollyDisabled(permission(registration.tool, name), rules)) continue
if (registration.codemode) codemode.set(name, registration)
else direct.set(name, registration)
}
const execute =
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
...(execute ? [definition("execute", execute)] : []),
],
settle: (input: ExecuteInput) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name)
if (registration) return settleTool(input, registration.tool)
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
},
}
}),
),
),
})
}),
)
const layer = Layer.effect(
Tools.Service,
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
Service.use((registry) =>
Effect.succeed(Tools.Service.of({ register: registry.register, registerBatch: registry.registerBatch })),
),
).pipe(Layer.provideMerge(registryLayer))
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
+7
View File
@@ -10,6 +10,13 @@ export interface Interface {
tools: Readonly<Record<string, Tool.AnyTool>>,
options?: Tool.RegisterOptions,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, Tool.AnyTool>>
readonly options?: Tool.RegisterOptions
}>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
/** Narrow registration-only Location capability. */
@@ -95,6 +95,21 @@ describe("ToolRegistry", () => {
}),
)
it.effect("validates a registration batch before installing any tools", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const error = yield* service
.registerBatch([
{ tools: { first: make() }, options: { codemode: false } },
{ tools: { second: make() }, options: { namespace: "invalid..namespace", codemode: false } },
])
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.materialize()).definitions).toEqual([])
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
+14 -29
View File
@@ -23,13 +23,7 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
@@ -99,13 +93,7 @@ describe("Snapshot", () => {
await fs.mkdir(location, { recursive: true })
await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
const layer = snapshotLayer(tmp.path, location)
@@ -168,14 +156,8 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet()
await initGit(project, true)
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
})
const capture = (directory: string) =>
@@ -213,13 +195,7 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
yield* Effect.gen(function* () {
@@ -251,3 +227,12 @@ function snapshotLayer(data: string, directory: string) {
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
}
async function initGit(directory: string, commit = false) {
await $`git init`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
if (!commit) return
await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`
.cwd(directory)
.quiet()
}
+3 -1
View File
@@ -14,7 +14,9 @@ The package is private while its API is explored. Its tests are the executable s
- Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`.
- Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically.
- Flatten path, query, header, and payload fields into one input object.
- Flatten path, query, header, and struct payload fields into one input object.
- Represent every non-struct payload (including unions, arrays, primitives, and records with index signatures) as one required `payload` input field and send that value as the request body directly.
- Reject an opaque `payload` field when its name collides with a path, query, or header field.
- Reject duplicate field names across input channels.
- Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required.
- Unwrap exact `{ data: A }` success envelopes.
+83 -30
View File
@@ -126,10 +126,18 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Constrain
...inputFields(headers?.schema, "headers", name),
...payloads.flatMap((item) => inputFields(item.schema, "payload", name)),
]
const names = new Set<string>()
const names = new Map<string, InputField["source"]>()
for (const field of inputs) {
if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` })
names.add(field.name)
const existing = names.get(field.name)
if (existing !== undefined) {
if (field.source === "payload" && payloads[0] !== undefined && !isFlattenableStruct(payloads[0].schema)) {
throw new GenerationError({
reason: `Opaque payload field collision: ${name}.payload conflicts with ${existing}.${field.name}`,
})
}
throw new GenerationError({ reason: `Input field collision: ${field.name}` })
}
names.set(field.name, field.source)
}
const schemaPaths: Array<readonly [string, Schema.Top]> = [
@@ -336,7 +344,7 @@ function renderEffectShape(groups: ReadonlyArray<Group>, options: { readonly mod
const input = endpoint.input
.map(
(field) =>
`readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`,
`readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}]${isOpaquePayload(endpoint) && field.source === "payload" ? "" : `[${JSON.stringify(field.name)}]`}`,
)
.join("; ")
const inputType = endpoint.operation.inputMode === "none" ? "" : `export type ${prefix}Input = { ${input} }`
@@ -474,26 +482,37 @@ function renderImportedEffectFiles(
const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]`
const methods = group.endpoints.map((item, endpointIndex) => {
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
const schemaBySource = {
params: item.params,
query: item.query,
headers: item.headers,
payload: item.payloads[0],
}
const request = (["params", "query", "headers", "payload"] as const)
.flatMap((source) => {
const fields = item.input.filter((field) => field.source === source)
if (fields.length === 0) return []
return [
`${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`).join(", ")} }`,
]
})
.map((source) =>
renderEffectRequestPart(
item.input,
item.operation.inputMode,
source,
isOpaquePayload(item),
schemaBySource[source] !== undefined,
),
)
.filter((part): part is string => part !== undefined)
.join(", ")
const input = item.input
.map(
(field) =>
`readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`,
`readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}]${isOpaquePayload(item) && field.source === "payload" ? "" : `[${JSON.stringify(field.name)}]`}`,
)
.join("; ")
const argument =
item.operation.inputMode === "none"
? ""
: `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input`
const rawCall = `raw[${JSON.stringify(item.endpoint.identifier)}]({ ${request} })`
// HttpApiClient distributes union payloads into union request objects, while rebuilding a flattened input
// produces one object containing a union value. The shapes are equivalent but TypeScript cannot correlate them.
const rawCall = `raw[${JSON.stringify(item.endpoint.identifier)}]({ ${request} }${isOpaquePayload(item) ? ` as ${prefix}Request` : ""})`
const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})`
return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.identifier)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}`
})
@@ -623,7 +642,7 @@ function renderPromiseTypes(
const schema = schemas[field.source]
if (schema === undefined)
throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]`
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${isOpaquePayload(endpoint) && field.source === "payload" ? typeOf(schema) : `(${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]`}`
})
.join("; ")
const successSchema = endpoint.successes[0]
@@ -693,8 +712,12 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
const part = (source: InputField["source"]) => {
const inputs = endpoint.input.filter((field) => field.source === source)
return inputs.length === 0
? undefined
: `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }`
? source === "payload" && endpoint.payloads.length > 0
? "{ }"
: undefined
: isOpaquePayload(endpoint) && source === "payload"
? access(inputs[0].name)
: `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }`
}
const parts = [
endpoint.query === undefined ? undefined : `query: ${part("query")}`,
@@ -1146,10 +1169,21 @@ export function generate<Id extends string, Groups extends HttpApiGroup.Constrai
}).pipe(Effect.flatMap((output) => write(output, options.directory)))
}
function isFlattenableStruct(schema: Schema.Top) {
const ast = Schema.toType(schema).ast
return SchemaAST.isObjects(ast) && ast.indexSignatures.length === 0
}
function isOpaquePayload(endpoint: Endpoint) {
const payload = endpoint.payloads[0]
return payload !== undefined && !isFlattenableStruct(payload)
}
function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) {
if (schema === undefined) return []
const ast = Schema.toType(schema).ast
if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) {
if (source === "payload") return [{ name: "payload", source, optional: false }] as const
throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` })
}
return ast.propertySignatures.map((field) => {
@@ -1416,7 +1450,7 @@ function renderGroup(group: Group, groupIndex: number) {
if (slot === undefined) {
throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.identifier}` })
}
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]`
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${isOpaquePayload(operation) && field.source === "payload" ? `typeof ${slot.name}.Type` : `(typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]`}`
})
.join("; ")
const argument =
@@ -1424,24 +1458,29 @@ function renderGroup(group: Group, groupIndex: number) {
? ""
: `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input`
const request = (["params", "query", "headers", "payload"] as const)
.flatMap((source) => {
const slot = schemaBySource[source]
if (slot === undefined) return []
const fields = operation.input
.filter((field) => field.source === source)
.map(
(field) =>
`${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`,
)
return [`${source}: { ${fields.join(", ")} }`]
})
.map((source) =>
renderEffectRequestPart(
operation.input,
operation.operation.inputMode,
source,
isOpaquePayload(operation),
schemaBySource[source] !== undefined,
),
)
.filter((part): part is string => part !== undefined)
.join(", ")
const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])]
const declaredSchema =
declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])`
const rawCall = `raw[${JSON.stringify(endpoint.identifier)}]({ ${request} })`
const opaquePayload = isOpaquePayload(operation)
// HttpApiClient distributes union payloads into union request objects, while rebuilding a flattened input
// produces one object containing a union value. The shapes are equivalent but TypeScript cannot correlate them.
const rawCall = `raw[${JSON.stringify(endpoint.identifier)}]({ ${request} }${opaquePayload ? ` as ${prefix}Request` : ""})`
const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})`
const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n`
const inputDeclaration =
operation.operation.inputMode === "none"
? ""
: `${opaquePayload ? `type ${prefix}Request = Parameters<RawGroup[${JSON.stringify(endpoint.identifier)}]>[0]\n` : ""}type ${prefix}Input = { ${inputType} }\n`
adapters.push(
`${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`,
)
@@ -1491,6 +1530,20 @@ function renderGroup(group: Group, groupIndex: number) {
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n`
}
function renderEffectRequestPart(
input: Endpoint["input"],
mode: Operation["inputMode"],
source: InputField["source"],
opaquePayload: boolean,
present = false,
) {
const fields = input.filter((field) => field.source === source)
if (fields.length === 0) return present ? `${source}: { }` : undefined
const access = (name: string) => `input${mode === "optional" ? "?." : ""}[${JSON.stringify(name)}]`
if (opaquePayload && source === "payload") return `${source}: ${access(fields[0].name)}`
return `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }`
}
function renderSchemas(slots: ReadonlyArray<Slot>) {
if (slots.length === 0) return ""
const classes = new Map(
+12
View File
@@ -27,6 +27,18 @@ export const Api = HttpApi.make("fixture")
params: { sessionID: Schema.String },
success: HttpApiSchema.NoContent,
}),
)
.add(
HttpApiEndpoint.post("configure", "/session/:sessionID/configure", {
params: { sessionID: Schema.String },
query: { dryRun: Schema.optional(Schema.Boolean) },
headers: { traceID: Schema.String },
payload: Schema.Union([
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
]),
success: Schema.String,
}),
),
)
.add(
@@ -773,6 +773,42 @@ describe("HttpApiCodegen.generate", () => {
}
})
test("serializes an opaque union payload as the direct JSON body", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.post("configure", "/session", {
payload: Schema.Union([
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
]),
success: HttpApiSchema.NoContent,
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("serializes explicit null query values", async () => {
const output = emitPromise(
compileContract(
@@ -971,6 +1007,74 @@ describe("HttpApiCodegen.generate", () => {
)
})
test("uses one opaque field for non-struct payloads across emitters", () => {
const source = api(
HttpApiEndpoint.post("configure", "/session/configure", {
payload: Schema.Union([
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
]),
success: Schema.String,
}),
)
const contract = compileContract(source)
const effect = emitEffect(contract)
const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" })
const promise = emitPromise(contract)
expect(effect.operations[0]).toMatchObject({
input: [{ name: "payload", source: "payload" }],
inputMode: "required",
})
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain('payload: input["payload"]')
expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain('payload: input["payload"]')
expect(shape.files[0]?.content).toContain('Endpoint0_0Request["payload"]')
expect(promise.files.find((file) => file.path === "types.ts")?.content).toContain(
'readonly "payload": { readonly "type": "local", readonly "command": ReadonlyArray<string> } | { readonly "type": "remote", readonly "url": string }',
)
expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain('body: input["payload"]')
})
test("routes arrays, primitives, and index-signature records through the opaque payload path", () => {
for (const payload of [Schema.Array(Schema.String), Schema.String, Schema.Record(Schema.String, Schema.Number)]) {
expect(
compileContract(api(HttpApiEndpoint.post("set", "/session", { payload, success: HttpApiSchema.NoContent })))
.groups[0]?.endpoints[0]?.operation.input,
).toEqual([{ name: "payload", source: "payload" }])
}
})
test("rejects an opaque payload field that collides with another input channel", () => {
expect(() =>
compileContract(
api(
HttpApiEndpoint.post("configure", "/session", {
query: { payload: Schema.String },
payload: Schema.Union([Schema.String, Schema.Number]),
success: Schema.String,
}),
),
),
).toThrow("Opaque payload field collision: session.configure.payload conflicts with query.payload")
})
test("preserves required empty struct payloads in imported Effect adapters", () => {
const contract = compileContract(
api(
HttpApiEndpoint.post("empty", "/session", {
payload: Schema.Struct({}),
success: Schema.String,
}),
),
)
const effect = emitEffectImported(contract, { module: "@example/api", api: "Api" })
const promise = emitPromise(contract)
expect(effect.files.find((file) => file.path === "client.ts")?.content).toContain("payload: { }")
expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain("body: { }")
})
test("uses no argument when an operation has no input fields", () => {
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
@@ -10,6 +10,12 @@ export const program = OpenCode.make().pipe(
const filtered = client.session.list({ archived: true })
const get = client.session.get({ sessionID: "session" })
const interrupt = client.session.interrupt({ sessionID: "session" })
const configure = client.session.configure({
sessionID: "session",
dryRun: true,
traceID: "trace",
payload: { type: "local", command: ["opencode"] },
})
const status = client.status()
const subscribe = client.event.subscribe()
@@ -18,10 +24,11 @@ export const program = OpenCode.make().pipe(
const _filtered: Effect.Effect<ReadonlyArray<string>, ClientError> = filtered
const _get: Effect.Effect<string, Missing | ClientError> = get
const _interrupt: Effect.Effect<void, ClientError> = interrupt
const _configure: Effect.Effect<string, ClientError> = configure
const _status: Effect.Effect<string, ClientError> = status
const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe
return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe }
return { _health, _list, _filtered, _get, _interrupt, _configure, _status, _subscribe }
}),
)
@@ -24,6 +24,19 @@ const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
const Endpoint4Params = Schema.Struct({ sessionID: Schema.String })
const Endpoint4Query = Schema.Struct({ dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
const Endpoint4Headers = Schema.Struct({ traceID: Schema.String })
const Endpoint4Payload0 = Schema.Union([
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
])
const Endpoint4Success = Schema.String
export const Group0 = HttpApiGroup.make("session", { topLevel: false })
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
@@ -40,6 +53,15 @@ export const Group0 = HttpApiGroup.make("session", { topLevel: false })
success: Endpoint3Success,
}),
)
.add(
HttpApiEndpoint.make("POST")("configure", "/session/:sessionID/configure", {
params: Endpoint4Params,
query: Endpoint4Query,
headers: Endpoint4Headers,
payload: Endpoint4Payload0,
success: Endpoint4Success,
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group0, never, never>
@@ -88,9 +110,32 @@ const mapEndpoint3Error = (error: unknown) =>
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
type Endpoint4Request = Parameters<RawGroup["configure"]>[0]
type Endpoint4Input = {
readonly sessionID: (typeof Endpoint4Params.Type)["sessionID"]
readonly dryRun?: (typeof Endpoint4Query.Type)["dryRun"]
readonly traceID: (typeof Endpoint4Headers.Type)["traceID"]
readonly payload: typeof Endpoint4Payload0.Type
}
const Endpoint4DeclaredError = Schema.Never
const mapEndpoint4Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint4DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint4 = (raw: RawGroup) => (input: Endpoint4Input) =>
raw["configure"]({
params: { sessionID: input["sessionID"] },
query: { dryRun: input["dryRun"] },
headers: { traceID: input["traceID"] },
payload: input["payload"],
} as Endpoint4Request).pipe(Effect.mapError(mapEndpoint4Error))
export const adaptGroup0 = (raw: RawGroup) => ({
health: Endpoint0(raw),
list: Endpoint1(raw),
get: Endpoint2(raw),
interrupt: Endpoint3(raw),
configure: Endpoint4(raw),
})
+9
View File
@@ -90,6 +90,15 @@ export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundErr
{ httpApiStatus: 404 },
) {}
export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNotFoundError>()(
"McpServerNotFoundError",
{
server: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class CommandNotFoundError extends Schema.TaggedErrorClass<CommandNotFoundError>()(
"CommandNotFoundError",
{
+68 -1
View File
@@ -1,7 +1,8 @@
import { Mcp } from "@opencode-ai/schema/mcp"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { McpServerNotFoundError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const McpGroup = HttpApiGroup.make("server.mcp")
@@ -19,6 +20,72 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
}),
),
)
.add(
HttpApiEndpoint.put("mcp.add", "/api/mcp/:server", {
params: { server: Schema.String },
query: LocationQuery,
// Wrapped in a struct because the client codegen flattens payload fields and cannot
// represent a top-level union payload.
payload: Schema.Struct({ config: Mcp.ServerConfig }),
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.add",
summary: "Add MCP server",
description: "Add an MCP server at runtime or replace an existing one, connecting it immediately.",
}),
),
)
.add(
HttpApiEndpoint.delete("mcp.remove", "/api/mcp/:server", {
params: { server: Schema.String },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: McpServerNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.remove",
summary: "Remove MCP server",
description: "Stop an MCP server and remove it from the runtime set until restart.",
}),
),
)
.add(
HttpApiEndpoint.post("mcp.connect", "/api/mcp/:server/connect", {
params: { server: Schema.String },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: McpServerNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.connect",
summary: "Connect MCP server",
description: "Connect an MCP server at runtime, overriding a disabled configuration until restart.",
}),
),
)
.add(
HttpApiEndpoint.post("mcp.disconnect", "/api/mcp/:server/disconnect", {
params: { server: Schema.String },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: McpServerNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.disconnect",
summary: "Disconnect MCP server",
description: "Disconnect an MCP server at runtime, removing its tools until reconnected.",
}),
),
)
.add(
HttpApiEndpoint.get("mcp.resource.catalog", "/api/mcp/resource", {
query: LocationQuery,
+50 -1
View File
@@ -1,9 +1,58 @@
export * as Mcp from "./mcp.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { optional, PositiveInt } from "./schema.js"
import { IntegrationID } from "./integration-id.js"
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
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: TimeoutConfig.pipe(Schema.optional),
}) {}
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
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 RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuthConfig, 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: TimeoutConfig.pipe(Schema.optional),
}) {}
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
export type ServerConfig = typeof ServerConfig.Type
const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
identifier: "Mcp.Status.Connected",
})
+37 -1
View File
@@ -1,9 +1,13 @@
import { MCP } from "@opencode-ai/core/mcp/index"
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
const notFound = <A, R>(effect: Effect.Effect<A, MCP.NotFoundError, R>) =>
effect.pipe(Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })))
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
Effect.gen(function* () {
return handlers
@@ -22,6 +26,38 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
)
}),
)
.handle(
"mcp.add",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
yield* service.add(ctx.params.server, ctx.payload.config)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"mcp.remove",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
yield* notFound(service.remove(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"mcp.connect",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
yield* notFound(service.connect(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"mcp.disconnect",
Effect.fn(function* (ctx) {
const service = yield* MCP.Service
yield* notFound(service.disconnect(ctx.params.server))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"mcp.resource.catalog",
Effect.fn(function* () {
+1
View File
@@ -24,6 +24,7 @@
"@napi-rs/canvas": "1.0.2",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"effect": "catalog:"
},
+8 -1
View File
@@ -1,5 +1,7 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Config, Effect, Layer } from "effect"
import { HttpClient } from "effect/unstable/http"
import { DriveManifest } from "../manifest"
@@ -41,7 +43,12 @@ export const simulationReplacements = Effect.fn("Simulation.replacements")(funct
}),
),
)
return [[httpClient, networkLayer]] satisfies LayerNode.Replacements
const networkNode = makeGlobalNode({
service: HttpClient.HttpClient,
layer: networkLayer,
deps: [SdkPlugins.node],
})
return [[httpClient, networkNode]] satisfies LayerNode.Replacements
})
export * as Simulation from "./index"
+26
View File
@@ -29,6 +29,32 @@ type FinishReason = Extract<SimulatedProvider.ProviderResponseEvent, { readonly
function chunkOf(item: ProviderItem): OpenAIChatEvent | unknown {
if (item.type === "textDelta") return { choices: [{ delta: { content: item.text } }] }
if (item.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: item.text } }] }
if (item.type === "toolInputStart")
return {
choices: [
{
delta: {
tool_calls: [
{
index: item.index,
id: item.id,
function: { name: item.name, arguments: "" },
},
],
},
},
],
}
if (item.type === "toolInputDelta")
return {
choices: [
{
delta: {
tool_calls: [{ index: item.index, function: { arguments: item.text } }],
},
},
],
}
if (item.type === "toolCall")
return {
choices: [
@@ -1,5 +1,25 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Cause, Context, Effect, Fiber, FiberSet, Layer, PubSub, Queue, Ref, Schema, Semaphore, Stream } from "effect"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { createHash } from "node:crypto"
import {
Cause,
Context,
Deferred,
Effect,
Exit,
Fiber,
FiberSet,
Layer,
PubSub,
Queue,
Ref,
Schema,
Scope,
Semaphore,
Stream,
} from "effect"
import { SimulationControlServer } from "../control-server"
import { SimulationProtocol } from "../protocol"
@@ -50,6 +70,68 @@ interface Driver {
readonly pending: () => Effect.Effect<readonly ProviderInvocation[]>
}
type ControlSocket = SimulationControlServer.Socket
interface ToolController {
readonly socket: ControlSocket
}
type ToolCompletion =
| { readonly type: "success"; readonly output: SimulationProtocol.Backend.ToolOutput }
| { readonly type: "failure"; readonly message: string }
interface PendingToolInvocation {
readonly id: string
readonly notification: SimulationProtocol.Backend.ToolInvocation
readonly progress: Tool.Context["progress"]
readonly completion: Deferred.Deferred<ToolCompletion>
readonly operations: Semaphore.Semaphore
update?: { readonly sequence: number; readonly fingerprint: string }
}
interface ToolReconciliation {
readonly generation: number
readonly result: Deferred.Deferred<Exit.Exit<void, unknown>>
}
interface ToolRegistrationUpdate {
readonly generation: number
readonly registrations: ReadonlyArray<SimulationProtocol.Backend.ToolRegistration>
}
interface ToolState {
readonly counter: number
readonly generation: number
readonly appliedGeneration: number
readonly controller?: ToolController
readonly registrations: ReadonlyArray<SimulationProtocol.Backend.ToolRegistration>
readonly pending: ReadonlyMap<string, PendingToolInvocation>
readonly completed: ReadonlyMap<string, string>
readonly activeOverlays: ReadonlySet<object>
readonly reconciliation: ReadonlyMap<object, ToolReconciliation>
}
interface ToolDriver {
readonly attach: (
socket: ControlSocket,
registrations: ReadonlyArray<SimulationProtocol.Backend.ToolRegistration>,
) => Effect.Effect<{ readonly attached: true }, ToolControllerError>
readonly update: (
socket: ControlSocket,
params: SimulationProtocol.Backend.ToolUpdateParams,
) => Effect.Effect<void, ToolInvocationNotFoundError | ToolControllerError>
readonly finish: (
socket: ControlSocket,
params: SimulationProtocol.Backend.ToolFinishParams,
) => Effect.Effect<void, ToolInvocationNotFoundError | ToolControllerError>
readonly fail: (
socket: ControlSocket,
params: SimulationProtocol.Backend.ToolFailParams,
) => Effect.Effect<void, ToolInvocationNotFoundError | ToolControllerError>
readonly release: (socket: ControlSocket) => Effect.Effect<void>
readonly shutdown: Effect.Effect<void>
}
class InvocationNotFoundError extends Schema.TaggedErrorClass<InvocationNotFoundError>()(
"SimulatedProvider.InvocationNotFoundError",
{ id: Schema.String, message: Schema.String },
@@ -60,7 +142,15 @@ class ControllerDisconnectedError extends Schema.TaggedErrorClass<ControllerDisc
{ message: Schema.String },
) {}
type ControlSocket = SimulationControlServer.Socket
class ToolInvocationNotFoundError extends Schema.TaggedErrorClass<ToolInvocationNotFoundError>()(
"SimulatedProvider.ToolInvocationNotFoundError",
{ id: Schema.String, message: Schema.String },
) {}
class ToolControllerError extends Schema.TaggedErrorClass<ToolControllerError>()(
"SimulatedProvider.ToolControllerError",
{ message: Schema.String },
) {}
export const layerDrive = (options: { readonly endpoint: string }) =>
Layer.effect(
@@ -180,13 +270,18 @@ export const layerDrive = (options: { readonly endpoint: string }) =>
const fibers = yield* FiberSet.make<void, unknown>()
const activeController = yield* Ref.make<Fiber.Fiber<void> | undefined>(undefined)
const controllerLock = yield* Semaphore.make(1)
const tools = yield* makeToolDriver()
yield* Effect.addFinalizer(() => tools.shutdown)
yield* SimulationControlServer.start({
endpoint: options.endpoint,
label: "opencode drive backend websocket",
data: () => ({}),
decode: SimulationProtocol.Backend.decodeRequestEffect,
handle: (socket, request) => handle(driver, fibers, activeController, controllerLock, socket, request),
close: (socket) => releaseController(activeController, controllerLock, socket),
handle: (socket, request) => handle(driver, tools, fibers, activeController, controllerLock, socket, request),
close: (socket) =>
Effect.all([releaseController(activeController, controllerLock, socket), tools.release(socket)], {
discard: true,
}),
})
yield* Effect.sync(() => process.stderr.write(`opencode drive backend websocket: ${options.endpoint}\n`))
@@ -205,6 +300,7 @@ export const layerDrive = (options: { readonly endpoint: string }) =>
function handle(
driver: Driver,
tools: ToolDriver,
fibers: FiberSet.FiberSet<void, unknown>,
activeController: Ref.Ref<Fiber.Fiber<void> | undefined>,
controllerLock: Semaphore.Semaphore,
@@ -259,9 +355,539 @@ function handle(
return driver.disconnect(request.params.id).pipe(Effect.as({ ok: true }))
case "llm.pending":
return driver.pending().pipe(Effect.map((invocations) => ({ invocations })))
case "tool.attach":
return tools.attach(socket, request.params.tools)
case "tool.update":
return tools.update(socket, request.params).pipe(Effect.as({ ok: true }))
case "tool.finish":
return tools.finish(socket, request.params).pipe(Effect.as({ ok: true }))
case "tool.fail":
return tools.fail(socket, request.params).pipe(Effect.as({ ok: true }))
}
}
const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* () {
const completedRetention = 256
const plugins = yield* SdkPlugins.Service
const state = yield* Ref.make<ToolState>({
counter: 0,
generation: 0,
appliedGeneration: 0,
registrations: [],
pending: new Map(),
completed: new Map(),
activeOverlays: new Set(),
reconciliation: new Map(),
})
const registrationUpdates = yield* PubSub.unbounded<ToolRegistrationUpdate>()
const lock = yield* Semaphore.make(1)
const attachmentLock = yield* Semaphore.make(1)
const remove = (current: ToolState, id: string) => {
const pending = new Map(current.pending)
pending.delete(id)
return { ...current, pending }
}
const complete = (current: ToolState, id: string, completion: string) => {
const completed = new Map(current.completed)
completed.set(id, completion)
if (completed.size > completedRetention) {
const oldest = completed.keys().next().value
if (oldest !== undefined) completed.delete(oldest)
}
return { ...remove(current, id), completed }
}
const notify = (
socket: ControlSocket,
method: "tool.invocation" | "tool.cancel",
params: SimulationProtocol.Backend.ToolInvocation | SimulationProtocol.Backend.ToolCancellation,
) =>
Effect.sync(() => {
socket.send(JSON.stringify({ jsonrpc: "2.0", method, params }))
})
const requireController = (socket: ControlSocket) =>
Effect.gen(function* () {
const current = yield* Ref.get(state)
if (current.controller?.socket === socket) return current
return yield* Effect.fail(new ToolControllerError({ message: "Drive tool controller is not attached" }))
})
const requireInvocation = (socket: ControlSocket, id: string) =>
Effect.gen(function* () {
const current = yield* requireController(socket)
const invocation = current.pending.get(id)
if (invocation) return { current, invocation }
return yield* Effect.fail(
new ToolInvocationNotFoundError({
id,
message: `Simulated tool invocation not found or already finished: ${id}`,
}),
)
})
const cancel = (id: string) =>
Effect.gen(function* () {
const invocation = yield* lock.withPermit(Ref.get(state).pipe(Effect.map((current) => current.pending.get(id))))
if (!invocation) return
yield* invocation.operations.withPermit(
lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
if (current.pending.get(id) !== invocation) return
yield* Ref.set(state, remove(current, id))
if (current.controller && !current.controller.socket.data.closed)
yield* notify(current.controller.socket, "tool.cancel", { id, reason: "interrupted" })
}),
),
)
})
const invoke = (
registrationGeneration: number,
name: string,
input: unknown,
context: Tool.Context,
): Effect.Effect<Tool.DynamicOutput, Tool.Failure> =>
Effect.gen(function* () {
const encoded = yield* Schema.decodeUnknownEffect(Schema.Json)(input).pipe(
Effect.mapError((error) => new Tool.Failure({ message: `Simulated tool input is not JSON: ${error.message}` })),
)
const invocation = yield* Effect.uninterruptibleMask((restore) =>
attachmentLock
.withPermit(
lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
if (current.generation !== registrationGeneration)
yield* Effect.fail(
new Tool.Failure({ message: `Simulated tool registration is no longer active: ${name}` }),
)
const id = `tool_${current.counter + 1}`
const completion = yield* Deferred.make<ToolCompletion>()
const notification: SimulationProtocol.Backend.ToolInvocation = {
id,
name,
input: encoded,
context: {
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
},
}
const pending: PendingToolInvocation = {
id,
notification,
progress: context.progress,
completion,
operations: Semaphore.makeUnsafe(1),
}
yield* Ref.set(state, {
counter: current.counter + 1,
generation: current.generation,
appliedGeneration: current.appliedGeneration,
...(current.controller === undefined ? {} : { controller: current.controller }),
registrations: current.registrations,
pending: new Map(current.pending).set(id, pending),
completed: current.completed,
activeOverlays: current.activeOverlays,
reconciliation: current.reconciliation,
})
if (current.controller && !current.controller.socket.data.closed)
yield* notify(current.controller.socket, "tool.invocation", notification)
return pending
}),
),
)
.pipe(
Effect.flatMap((pending) =>
restore(Deferred.await(pending.completion)).pipe(
Effect.onInterrupt(() => cancel(pending.id)),
Effect.ensuring(
lock.withPermit(
Ref.update(state, (current) =>
current.pending.get(pending.id) === pending ? remove(current, pending.id) : current,
),
),
),
),
),
),
)
if (invocation.type === "success") return invocation.output
return yield* Effect.fail(new Tool.Failure({ message: invocation.message }))
})
yield* plugins.register(
Plugin.define({
id: "opencode.simulation.tools",
effect: (ctx) =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
const token = {}
const registrationLock = Semaphore.makeUnsafe(1)
let currentScope: Scope.Closeable | undefined
const reconcile = (
generation: number,
nextRegistrations: ReadonlyArray<SimulationProtocol.Backend.ToolRegistration>,
) =>
registrationLock.withPermit(
Effect.gen(function* () {
const nextScope = yield* Scope.fork(scope)
const applied = yield* Effect.exit(
ctx.tool
.transform((draft) => {
for (const registration of nextRegistrations)
draft.add(
registration.name,
Tool.make({
description: registration.description,
jsonSchema: registration.inputSchema,
...(registration.outputSchema === undefined
? {}
: { outputSchema: registration.outputSchema }),
...(registration.permission === undefined ? {} : { permission: registration.permission }),
execute: (input, context) =>
invoke(
generation,
SimulationProtocol.Backend.exposedToolName(registration),
input,
context,
),
}),
registration.options,
)
})
.pipe(Scope.provide(nextScope)),
)
if (Exit.isFailure(applied)) {
yield* Scope.close(nextScope, applied)
yield* Effect.failCause(applied.cause)
}
const previousScope = currentScope
currentScope = nextScope
if (previousScope) yield* Scope.close(previousScope, Exit.void)
}),
)
const acknowledge = (generation: number, result: Exit.Exit<void, unknown>) =>
lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
const reconciliation = current.reconciliation.get(token)
if (reconciliation?.generation !== generation) return
yield* Deferred.succeed(reconciliation.result, result)
}),
)
const initialized = yield* lock.withPermit(
Effect.gen(function* () {
const subscription = yield* PubSub.subscribe(registrationUpdates)
const current = yield* Ref.get(state)
const activeOverlays = new Set(current.activeOverlays).add(token)
yield* Ref.set(state, { ...current, activeOverlays })
return { subscription, generation: current.generation, registrations: current.registrations }
}),
)
yield* Effect.addFinalizer(() =>
lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
const activeOverlays = new Set(current.activeOverlays)
activeOverlays.delete(token)
const reconciliation = new Map(current.reconciliation)
const pending = reconciliation.get(token)
reconciliation.delete(token)
yield* Ref.set(state, { ...current, activeOverlays, reconciliation })
if (pending) yield* Deferred.succeed(pending.result, Exit.void)
}),
),
)
yield* reconcile(initialized.generation, initialized.registrations)
yield* Stream.fromEffectRepeat(PubSub.take(initialized.subscription)).pipe(
Stream.runForEach((update) =>
Effect.gen(function* () {
const result = yield* Effect.exit(reconcile(update.generation, update.registrations))
yield* acknowledge(update.generation, result)
}),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
}),
)
const reconcileRegistrations = (
controller: ToolController | undefined,
registrations: ReadonlyArray<SimulationProtocol.Backend.ToolRegistration>,
targetGeneration?: number,
) =>
Effect.gen(function* () {
const reconciliation = yield* lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
const generation = targetGeneration ?? current.generation + 1
const reconciliation = new Map<object, ToolReconciliation>()
for (const token of current.activeOverlays)
reconciliation.set(token, {
generation,
result: Deferred.makeUnsafe<Exit.Exit<void, unknown>>(),
})
yield* Ref.set(state, {
counter: current.counter,
generation,
appliedGeneration: current.appliedGeneration,
...(controller === undefined ? {} : { controller }),
registrations,
pending: current.pending,
completed: current.completed,
activeOverlays: current.activeOverlays,
reconciliation,
})
yield* PubSub.publish(registrationUpdates, { generation, registrations })
return { generation, previous: current, reconciliation }
}),
)
const results = yield* Effect.forEach(reconciliation.reconciliation.values(), (item) =>
Deferred.await(item.result),
)
const failure = results.find(Exit.isFailure)
yield* lock.withPermit(
Ref.update(state, (current) =>
current.generation === reconciliation.generation
? {
...current,
...(failure === undefined ? { appliedGeneration: reconciliation.generation } : {}),
reconciliation: new Map(),
}
: current,
),
)
return { failure, previous: reconciliation.previous }
})
const attach: ToolDriver["attach"] = (socket, registrations) =>
attachmentLock.withPermit(
Effect.gen(function* () {
if (socket.data.closed)
return yield* Effect.fail(
new ToolControllerError({ message: "Drive tool controller disconnected before attachment" }),
)
const current = yield* Ref.get(state)
if (current.controller && current.controller.socket !== socket && !current.controller.socket.data.closed)
return yield* Effect.fail(
new ToolControllerError({ message: "Another Drive tool controller is already attached" }),
)
const controller: ToolController = { socket }
const replay = current.controller?.socket !== socket
const sameRegistrations =
current.appliedGeneration === current.generation &&
fingerprintJson(current.registrations) === fingerprintJson(registrations)
if (replay && current.pending.size > 0 && !sameRegistrations)
return yield* Effect.fail(
new ToolControllerError({
message: "A reconnecting Drive tool controller must settle pending invocations before replacing tools",
}),
)
if (sameRegistrations) {
yield* lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
yield* Ref.set(state, { ...current, controller })
if (replay)
yield* Effect.forEach(
current.pending.values(),
(invocation) => notify(socket, "tool.invocation", invocation.notification),
{ discard: true },
)
}),
)
return { attached: true as const }
}
const reconciled = yield* reconcileRegistrations(controller, registrations)
if (reconciled.failure) {
const rolledBack = yield* reconcileRegistrations(
reconciled.previous.controller,
reconciled.previous.registrations,
reconciled.previous.generation,
)
return yield* Effect.fail(
new ToolControllerError({
message: rolledBack.failure
? `Failed to apply and restore simulated tools: ${Cause.pretty(reconciled.failure.cause)}; ${Cause.pretty(rolledBack.failure.cause)}`
: `Failed to apply simulated tools: ${Cause.pretty(reconciled.failure.cause)}`,
}),
)
}
if (replay)
yield* lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
if (current.controller?.socket !== socket) return
yield* Effect.forEach(
current.pending.values(),
(invocation) => notify(socket, "tool.invocation", invocation.notification),
{ discard: true },
)
}),
)
return { attached: true as const }
}),
)
const update: ToolDriver["update"] = (socket, params) =>
Effect.gen(function* () {
const { invocation } = yield* lock.withPermit(requireInvocation(socket, params.id))
yield* invocation.operations.withPermit(
Effect.gen(function* () {
const current = yield* lock.withPermit(Ref.get(state))
if (current.pending.get(params.id) !== invocation)
yield* Effect.fail(
new ToolInvocationNotFoundError({
id: params.id,
message: `Simulated tool invocation not found or already finished: ${params.id}`,
}),
)
const fingerprint = fingerprintJson(params.update)
const applied = invocation.update
if (applied?.sequence === params.sequence && applied.fingerprint === fingerprint) return
if (applied?.sequence === params.sequence)
yield* Effect.fail(
new ToolControllerError({
message: `Simulated tool update sequence ${params.sequence} was reused with different progress`,
}),
)
const expected = applied === undefined ? 0 : applied.sequence + 1
if (params.sequence !== expected)
yield* Effect.fail(
new ToolControllerError({
message: `Expected simulated tool update sequence ${expected}, received ${params.sequence}`,
}),
)
yield* invocation.progress(params.update)
invocation.update = { sequence: params.sequence, fingerprint }
}),
)
})
const settle = (socket: ControlSocket, id: string, completion: ToolCompletion) =>
Effect.gen(function* () {
const current = yield* lock.withPermit(requireController(socket))
const fingerprint = fingerprintJson(completion)
const invocation = current.pending.get(id)
if (!invocation) {
if (current.completed.get(id) === fingerprint) return
yield* Effect.fail(
new ToolInvocationNotFoundError({
id,
message: `Simulated tool invocation not found or already finished: ${id}`,
}),
)
return
}
yield* invocation.operations.withPermit(
Effect.uninterruptible(
lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
if (current.pending.get(id) !== invocation) {
if (current.completed.get(id) === fingerprint) return
yield* Effect.fail(
new ToolInvocationNotFoundError({
id,
message: `Simulated tool invocation not found or already finished: ${id}`,
}),
)
return
}
yield* Ref.set(state, complete(current, id, fingerprint))
yield* Deferred.succeed(invocation.completion, completion)
}),
),
),
)
})
const release: ToolDriver["release"] = (socket) =>
attachmentLock.withPermit(
lock.withPermit(
Ref.update(
state,
(current): ToolState =>
current.controller?.socket !== socket
? current
: {
counter: current.counter,
generation: current.generation,
appliedGeneration: current.appliedGeneration,
registrations: current.registrations,
pending: current.pending,
completed: current.completed,
activeOverlays: current.activeOverlays,
reconciliation: current.reconciliation,
},
),
),
)
const shutdown = attachmentLock.withPermit(
Effect.gen(function* () {
yield* reconcileRegistrations(undefined, [])
const current = yield* lock.withPermit(
Effect.gen(function* () {
const current = yield* Ref.get(state)
yield* Ref.set(state, {
counter: current.counter,
generation: current.generation,
appliedGeneration: current.appliedGeneration,
registrations: [],
pending: new Map(),
completed: current.completed,
activeOverlays: new Set<object>(),
reconciliation: new Map(),
})
return current
}),
)
yield* Effect.forEach(
current.pending.values(),
(invocation) =>
Deferred.succeed(invocation.completion, {
type: "failure",
message: "Simulated tool controller shut down",
}),
{ discard: true },
)
yield* PubSub.shutdown(registrationUpdates)
}),
)
return {
attach,
update,
finish: (socket, params) => settle(socket, params.id, { type: "success", output: params.output }),
fail: (socket, params) => settle(socket, params.id, { type: "failure", message: params.message }),
release,
shutdown,
} satisfies ToolDriver
})
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`
if (typeof value === "object" && value !== null) {
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`
}
return JSON.stringify(value) ?? String(value)
}
function fingerprintJson(value: unknown) {
return createHash("sha256").update(canonicalJson(value)).digest("base64url")
}
function releaseController(
activeController: Ref.Ref<Fiber.Fiber<void> | undefined>,
controllerLock: Semaphore.Semaphore,
+48 -5
View File
@@ -9,9 +9,10 @@ import {
type MockInput,
type MockMouse,
} from "@opentui/core/testing"
import { Config, Effect, FileSystem } from "effect"
import type { SimulationProtocol } from "../protocol"
import { Config, Effect, FileSystem, Schema } from "effect"
import { SimulationProtocol } from "../protocol"
import { SimulationRenderer } from "./renderer"
import { SimulationSemantics } from "./semantics"
export type Action = SimulationProtocol.Frontend.Action
export type Element = SimulationProtocol.Frontend.Element
@@ -60,7 +61,8 @@ function hit(renderer: CliRenderer, renderable: Renderable) {
if (renderable.width <= 0 || renderable.height <= 0) return false
const x = Math.floor(renderable.screenX + renderable.width / 2)
const y = Math.floor(renderable.screenY + renderable.height / 2)
return renderer.hitTest(x, y) === renderable.num
const target = renderer.hitTest(x, y)
return all(renderable).some((item) => item.num === target)
}
/**
@@ -122,6 +124,25 @@ export function state(harness: Harness) {
}
}
export function snapshot(harness: Harness): SimulationProtocol.Frontend.SemanticSnapshot {
const ids = new Set<string>()
const visit = (renderable: Renderable, parent?: string): SimulationProtocol.Frontend.SemanticNode[] => {
if (!renderable.visible || renderable.isDestroyed) return []
const definition = SimulationSemantics.read(renderable)?.()
if (definition && ids.has(renderable.id)) throw new Error(`duplicate semantic UI id: ${renderable.id}`)
if (definition) ids.add(renderable.id)
const node = definition
? [{ id: renderable.id, ...definition, ...(parent === undefined ? {} : { parent }), element: renderable.num }]
: []
const ancestor = definition ? renderable.id : parent
return [...node, ...children(renderable).flatMap((child) => visit(child, ancestor))]
}
return Schema.decodeUnknownSync(SimulationProtocol.Frontend.SemanticSnapshot)({
format: "opencode-ui-snapshot-v1",
nodes: visit(harness.renderer.root),
})
}
export function matches(harness: Pick<Harness, "screen">, text: string) {
return harness.screen().includes(text)
}
@@ -183,9 +204,31 @@ export const execute = Effect.fn("SimulationActions.execute")(function* (harness
.find((item) => item.num === action.target)
?.focus()
break
case "ui.click":
yield* Effect.tryPromise(() => harness.mockMouse.click(action.x, action.y))
case "ui.click": {
const target = all(harness.renderer.root).find((item) => item.num === action.target)
if (!target || !target.visible || target.isDestroyed)
return yield* Effect.fail(new Error(`click target is stale or unavailable: ${action.target}`))
if (action.semantic) {
const current = snapshot(harness).nodes.find((node) => node.element === action.target)
if (
current?.id !== action.semantic.id ||
current.instance !== action.semantic.instance ||
current.element !== action.semantic.element
)
return yield* Effect.fail(new Error(`semantic click target is stale or unavailable: ${action.semantic.id}`))
}
if (
!Number.isFinite(action.x) ||
action.x < 0 ||
action.x >= target.width ||
!Number.isFinite(action.y) ||
action.y < 0 ||
action.y >= target.height
)
return yield* Effect.fail(new Error("click position must be within the target element"))
yield* Effect.tryPromise(() => harness.mockMouse.click(target.screenX + action.x, target.screenY + action.y))
break
}
case "ui.resize":
if (
!Number.isSafeInteger(action.cols) ||
@@ -0,0 +1,20 @@
import type { Renderable } from "@opentui/core"
import type { SimulationProtocol } from "../protocol"
// Semantic renderables set an explicit stable OpenTUI id so ui.state and
// ui.snapshot expose the same identity. Hierarchy and element handles come
// from the live render tree.
export type Definition = Omit<SimulationProtocol.Frontend.SemanticNode, "id" | "element" | "parent">
const key = Symbol.for("opencode.simulation.semantics")
const bind = (definition: () => Definition) => (renderable: Renderable) => {
Object.defineProperty(renderable, key, { value: definition, configurable: true })
}
export const read = (renderable: Renderable) => {
const definition: unknown = Reflect.get(renderable, key)
return typeof definition === "function" ? (definition as () => Definition) : undefined
}
export const SimulationSemantics = { bind, read }
@@ -22,6 +22,8 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request)
return SimulationActions.screenshot(harness, request.params?.name)
case "ui.state":
return Effect.sync(() => SimulationActions.state(harness))
case "ui.snapshot":
return Effect.sync(() => SimulationActions.snapshot(harness))
case "ui.matches":
return Effect.sync(() => SimulationActions.matches(harness, request.params.text))
case "ui.recording.finish":
@@ -46,6 +48,7 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request)
target: request.params.target,
x: request.params.x,
y: request.params.y,
semantic: request.params.semantic,
})
case "ui.resize":
return SimulationActions.execute(harness, {
+198 -6
View File
@@ -67,9 +67,10 @@ export namespace Handshake {
export const Params = Schema.Struct({
client: Identity,
expectedRole: EndpointRole,
offeredVersions: Schema.Array(
Schema.Int.check(Schema.isGreaterThan(0)),
).check(Schema.isMinLength(1), Schema.isUnique()),
offeredVersions: Schema.Array(Schema.Int.check(Schema.isGreaterThan(0))).check(
Schema.isMinLength(1),
Schema.isUnique(),
),
requiredCapabilities: Schema.Array(Capability).check(Schema.isUnique()),
optionalCapabilities: Schema.Array(Capability).check(Schema.isUnique()),
})
@@ -170,10 +171,12 @@ export namespace Frontend {
"ui.arrow",
"ui.focus",
"ui.click",
"ui.click.semantic",
"ui.resize",
"ui.matches",
"ui.screenshot",
"ui.state",
"ui.snapshot",
"ui.capture",
"ui.recording.finish",
] as const satisfies ReadonlyArray<Handshake.Capability>
@@ -187,13 +190,26 @@ export namespace Frontend {
})
export interface KeyModifiers extends Schema.Schema.Type<typeof KeyModifiers> {}
export const SemanticClickTarget = Schema.Struct({
id: Schema.NonEmptyString,
instance: Schema.optionalKey(Schema.NonEmptyString),
element: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)),
})
export interface SemanticClickTarget extends Schema.Schema.Type<typeof SemanticClickTarget> {}
export const Action = Schema.Union([
Schema.Struct({ type: Schema.Literal("ui.type"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("ui.press"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }),
Schema.Struct({ type: Schema.Literal("ui.enter") }),
Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }),
Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }),
Schema.Struct({ type: Schema.Literal("ui.click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }),
Schema.Struct({
type: Schema.Literal("ui.click"),
target: Schema.Number,
x: Schema.Number,
y: Schema.Number,
semantic: Schema.optionalKey(SemanticClickTarget),
}),
Schema.Struct({ type: Schema.Literal("ui.resize"), cols: Schema.Number, rows: Schema.Number }),
])
export type Action = Schema.Schema.Type<typeof Action>
@@ -221,6 +237,46 @@ export namespace Frontend {
})
export interface State extends Schema.Schema.Type<typeof State> {}
export const SemanticNode = Schema.Struct({
id: Schema.NonEmptyString,
instance: Schema.optionalKey(Schema.NonEmptyString),
parent: Schema.optionalKey(Schema.NonEmptyString),
role: Schema.NonEmptyString,
label: Schema.optionalKey(Schema.NonEmptyString),
element: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)),
focused: Schema.optionalKey(Schema.Boolean),
selected: Schema.optionalKey(Schema.Boolean),
expanded: Schema.optionalKey(Schema.Boolean),
disabled: Schema.optionalKey(Schema.Boolean),
})
export interface SemanticNode extends Schema.Schema.Type<typeof SemanticNode> {}
export const SemanticSnapshot = Schema.Struct({
format: Schema.Literal("opencode-ui-snapshot-v1"),
nodes: Schema.Array(SemanticNode).check(
Schema.makeFilter((nodes) => {
const ids = new Set(nodes.map((node) => node.id))
if (ids.size !== nodes.length) return "semantic node ids must be unique"
if (new Set(nodes.map((node) => node.element)).size !== nodes.length)
return "semantic node elements must be unique"
if (nodes.some((node) => node.parent !== undefined && !ids.has(node.parent)))
return "semantic node parents must reference another node"
const parents = new Map(nodes.map((node) => [node.id, node.parent]))
for (const node of nodes) {
const visited = new Set<string>()
let current: string | undefined = node.id
while (current !== undefined) {
if (visited.has(current)) return "semantic node hierarchy must be acyclic"
visited.add(current)
current = parents.get(current)
}
}
return undefined
}),
),
})
export interface SemanticSnapshot extends Schema.Schema.Type<typeof SemanticSnapshot> {}
export const Screenshot = Schema.String
export type Screenshot = Schema.Schema.Type<typeof Screenshot>
@@ -271,7 +327,12 @@ export namespace Frontend {
export const FocusParams = Schema.Struct({ target: Schema.Number })
export interface FocusParams extends Schema.Schema.Type<typeof FocusParams> {}
export const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number })
export const ClickParams = Schema.Struct({
target: Schema.Number,
x: Schema.Number,
y: Schema.Number,
semantic: Schema.optionalKey(SemanticClickTarget),
})
export interface ClickParams extends Schema.Schema.Type<typeof ClickParams> {}
export const ResizeParams = Schema.Struct({ cols: Schema.Number, rows: Schema.Number })
@@ -293,7 +354,7 @@ export namespace Frontend {
}),
Schema.Struct({
...JsonRpc.RequestFields,
method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]),
method: Schema.Literals(["ui.enter", "ui.state", "ui.snapshot", "ui.recording.finish"]),
}),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.capture") }),
])
@@ -310,11 +371,29 @@ export namespace Backend {
"llm.disconnect",
"llm.pending",
"llm.request",
"llm.tool-input-delta",
"tool.attach",
"tool.update",
"tool.finish",
"tool.fail",
"tool.invocation",
"tool.cancel",
] as const satisfies ReadonlyArray<Handshake.Capability>
export const Item = Schema.Union([
Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("toolInputStart"),
index: Schema.Number,
id: Schema.String,
name: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("toolInputDelta"),
index: Schema.Number,
text: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("toolCall"),
index: Schema.Number,
@@ -329,6 +408,115 @@ export namespace Backend {
export const FinishReason = Schema.Literals(["stop", "tool-calls", "length", "content-filter"])
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const ToolContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("file"),
data: Schema.String,
mime: Schema.NonEmptyString,
name: Schema.optionalKey(Schema.String),
}),
])
export type ToolContent = Schema.Schema.Type<typeof ToolContent>
const ToolName = Schema.NonEmptyString.check(
Schema.makeFilter((name) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) ? undefined : "simulated tool names must be provider-safe",
),
)
const ToolNamespace = Schema.NonEmptyString.check(
Schema.makeFilter((namespace) =>
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
? undefined
: "simulated tool namespaces must contain provider-safe segments",
),
)
export const ToolRegistration = Schema.Struct({
name: ToolName,
description: Schema.String,
inputSchema: Schema.Record(Schema.String, Schema.Json),
outputSchema: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
permission: Schema.optionalKey(Schema.NonEmptyString),
options: Schema.optionalKey(
Schema.Struct({
namespace: Schema.optionalKey(ToolNamespace),
codemode: Schema.optionalKey(Schema.Boolean),
}),
),
})
export interface ToolRegistration extends Schema.Schema.Type<typeof ToolRegistration> {}
export const ToolAttachParams = Schema.Struct({
tools: Schema.Array(ToolRegistration).check(
Schema.makeFilter((tools) => {
const names = tools.map(exposedToolName)
if (names.some((name) => !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)))
return "simulated tool names including namespaces must be provider-safe"
if (new Set(names).size !== names.length) return "simulated tool registrations must have unique exposed names"
if (
tools.some(
(tool) =>
tool.name === "execute" && tool.options?.namespace === undefined && tool.options?.codemode === false,
)
)
return 'direct simulated tool name "execute" is reserved'
return undefined
}),
),
})
export interface ToolAttachParams extends Schema.Schema.Type<typeof ToolAttachParams> {}
export function exposedToolName(registration: ToolRegistration) {
return registration.options?.namespace === undefined
? registration.name
: `${registration.options.namespace.replaceAll(".", "_")}_${registration.name}`
}
export const ToolProgress = Schema.Struct({
structured: Schema.Record(Schema.String, Schema.Json),
content: Schema.optionalKey(Schema.Array(ToolContent)),
})
export interface ToolProgress extends Schema.Schema.Type<typeof ToolProgress> {}
export const ToolOutput = Schema.Struct({
structured: Schema.Json,
content: Schema.Array(ToolContent),
})
export interface ToolOutput extends Schema.Schema.Type<typeof ToolOutput> {}
export const ToolUpdateParams = Schema.Struct({
id: Schema.String,
sequence: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
update: ToolProgress,
})
export interface ToolUpdateParams extends Schema.Schema.Type<typeof ToolUpdateParams> {}
export const ToolFinishParams = Schema.Struct({ id: Schema.String, output: ToolOutput })
export interface ToolFinishParams extends Schema.Schema.Type<typeof ToolFinishParams> {}
export const ToolFailParams = Schema.Struct({ id: Schema.String, message: Schema.String })
export interface ToolFailParams extends Schema.Schema.Type<typeof ToolFailParams> {}
export const ToolInvocation = Schema.Struct({
id: Schema.String,
name: Schema.String,
input: Schema.Json,
context: Schema.Struct({
sessionID: Schema.String,
agent: Schema.String,
messageID: Schema.String,
callID: Schema.String,
}),
})
export interface ToolInvocation extends Schema.Schema.Type<typeof ToolInvocation> {}
export const ToolCancellation = Schema.Struct({
id: Schema.String,
reason: Schema.Literal("interrupted"),
})
export interface ToolCancellation extends Schema.Schema.Type<typeof ToolCancellation> {}
export const ChunkParams = Schema.Struct({ id: Schema.String, items: Schema.Array(Item) })
export interface ChunkParams extends Schema.Schema.Type<typeof ChunkParams> {}
@@ -346,6 +534,10 @@ export namespace Backend {
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.attach"), params: ToolAttachParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.update"), params: ToolUpdateParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.finish"), params: ToolFinishParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.fail"), params: ToolFailParams }),
Schema.Struct({
...JsonRpc.RequestFields,
method: Schema.Literals(["llm.attach", "llm.pending"]),
+197 -1
View File
@@ -1,6 +1,9 @@
import { expect, test } from "bun:test"
import { BoxRenderable, TextRenderable } from "@opentui/core"
import { Effect } from "effect"
import { execute, type Harness, matches } from "../src/frontend/actions"
import { createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
import { SimulationRenderer } from "../src/frontend/renderer"
import { SimulationSemantics } from "../src/frontend/semantics"
test("matches literal screen text", () => {
const harness = { screen: () => "OpenCode [ready].*" }
@@ -39,3 +42,196 @@ test("normalizes named keys for OpenTUI", async () => {
["x", undefined],
])
})
test("clicks a target at relative coordinates through descendant text", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
let clicks = 0
const button = new BoxRenderable(renderer, {
id: "permission.action.once",
width: 12,
height: 1,
onMouseUp: () => clicks++,
})
button.add(new TextRenderable(renderer, { content: "Allow once" }))
renderer.root.add(button)
const harness = createHarness(renderer)
yield* Effect.promise(() => harness.renderOnce())
expect(state(harness).elements).toContainEqual(expect.objectContaining({ id: button.id, clickable: true }))
yield* execute(harness, { type: "ui.click", target: button.num, x: 1, y: 0 })
expect(clicks).toBe(1)
renderer.root.remove(button)
const error = yield* execute(harness, { type: "ui.click", target: button.num, x: 1, y: 0 }).pipe(Effect.flip)
expect(error.message).toContain("click target is stale or unavailable")
}),
),
)
})
test("rejects a semantic click when the live identity does not match", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
let clicks = 0
const button = new BoxRenderable(renderer, {
id: "session.permission.action.once",
width: 12,
height: 1,
onMouseUp: () => clicks++,
})
SimulationSemantics.bind(() => ({
instance: "permission-2",
role: "option",
label: "Allow once",
}))(button)
renderer.root.add(button)
const harness = createHarness(renderer)
yield* Effect.promise(() => harness.renderOnce())
const error = yield* execute(harness, {
type: "ui.click",
target: button.num,
x: 1,
y: 0,
semantic: {
id: "session.permission.action.once",
instance: "permission-1",
element: button.num,
},
}).pipe(Effect.flip)
expect(error.message).toContain("semantic click target is stale or unavailable")
expect(clicks).toBe(0)
yield* execute(harness, {
type: "ui.click",
target: button.num,
x: 1,
y: 0,
semantic: {
id: "session.permission.action.once",
instance: "permission-2",
element: button.num,
},
})
expect(clicks).toBe(1)
}),
),
)
})
test("snapshots lazy semantic hierarchy and interaction state", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
let selected = "once"
const dialog = new BoxRenderable(renderer, { id: "session.permission" })
const actions = new BoxRenderable(renderer, { id: "session.permission.actions" })
const once = new BoxRenderable(renderer, { id: "session.permission.action.once" })
SimulationSemantics.bind(() => ({
instance: "permission-1",
role: "dialog",
label: "Permission required",
expanded: false,
}))(dialog)
SimulationSemantics.bind(() => ({
instance: "permission-1",
role: "listbox",
label: "Permission choices",
}))(actions)
SimulationSemantics.bind(() => ({
instance: "permission-1",
role: "option",
label: "Allow once",
focused: selected === "once",
selected: selected === "once",
disabled: false,
}))(once)
renderer.root.add(dialog)
dialog.add(actions)
actions.add(once)
expect(snapshot(createHarness(renderer))).toEqual({
format: "opencode-ui-snapshot-v1",
nodes: [
{
id: "session.permission",
instance: "permission-1",
role: "dialog",
label: "Permission required",
element: dialog.num,
expanded: false,
},
{
id: "session.permission.actions",
instance: "permission-1",
parent: "session.permission",
role: "listbox",
label: "Permission choices",
element: actions.num,
},
{
id: "session.permission.action.once",
instance: "permission-1",
parent: "session.permission.actions",
role: "option",
label: "Allow once",
element: once.num,
focused: true,
selected: true,
disabled: false,
},
],
})
selected = "reject"
expect(snapshot(createHarness(renderer)).nodes.at(-1)).toMatchObject({
id: "session.permission.action.once",
focused: false,
selected: false,
})
dialog.visible = false
expect(snapshot(createHarness(renderer)).nodes).toEqual([])
}),
),
)
})
test("rejects duplicate semantic identities", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
const first = new BoxRenderable(renderer, { id: "duplicate" })
const second = new BoxRenderable(renderer, { id: "duplicate" })
const definition = () => ({ role: "option" })
SimulationSemantics.bind(definition)(first)
SimulationSemantics.bind(definition)(second)
renderer.root.add(first)
renderer.root.add(second)
expect(() => snapshot(createHarness(renderer))).toThrow("duplicate semantic UI id: duplicate")
}),
),
)
})
test("validates lazy semantic definitions before returning them", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
const invalid = new BoxRenderable(renderer, { id: "invalid" })
SimulationSemantics.bind(() => ({ role: "" }))(invalid)
renderer.root.add(invalid)
expect(() => snapshot(createHarness(renderer))).toThrow()
}),
),
)
})
@@ -39,7 +39,12 @@ test("scopes the frontend control server and reports malformed JSON", async () =
protocolVersion: 1,
role: "ui",
server: { name: "opencode", version: expect.any(String) },
capabilities: expect.arrayContaining(["ui.state", "ui.capture"]),
capabilities: expect.arrayContaining([
"ui.state",
"ui.snapshot",
"ui.click.semantic",
"ui.capture",
]),
},
})
@@ -60,6 +65,13 @@ test("scopes the frontend control server and reports malformed JSON", async () =
},
})
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 3, method: "ui.snapshot" }))
expect(yield* Queue.take(messages)).toEqual({
jsonrpc: "2.0",
id: 3,
result: { format: "opencode-ui-snapshot-v1", nodes: [] },
})
socket.send("{")
expect(yield* Queue.take(messages)).toMatchObject({
id: null,
+29
View File
@@ -33,6 +33,35 @@ test("encodes every simulated provider event as OpenAI SSE", async () => {
)
})
test("encodes provider-neutral partial tool input as OpenAI deltas", async () => {
const provider: SimulatedProvider.Interface = {
stream: () =>
Stream.make(
{ type: "toolInputStart", index: 0, id: "call_lookup", name: "lookup" },
{ type: "toolInputDelta", index: 0, text: '{"query":' },
{ type: "toolInputDelta", index: 0, text: '"OpenCode"}' },
{ type: "finish", reason: "tool-calls" },
),
}
const url = new URL(DEFAULT_BASE_URL + PATH)
const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe({ model: "gpt-5" }))
const matched = SimulationOpenAI.route(provider).match(request, url)
if (!matched) throw new Error("The simulated OpenAI route did not match")
const body = await Effect.runPromise(matched.pipe(Effect.flatMap((response) => response.text)))
expect(body).toBe(
[
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_lookup","function":{"name":"lookup","arguments":""}}]}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"query\\":"}}]}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"OpenCode\\"}"}}]}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
"data: [DONE]",
"",
].join("\n\n"),
)
})
test("rejects malformed intercepted OpenAI JSON as an HTTP client error", async () => {
const provider: SimulatedProvider.Interface = { stream: () => Stream.empty }
const url = new URL(DEFAULT_BASE_URL + PATH)
+167 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { Backend, Frontend, Handshake } from "../src/protocol"
test("decodes ui.matches text params", () => {
@@ -21,6 +21,172 @@ test("decodes ui.matches text params", () => {
).toThrow()
})
test("decodes semantic click identity", () => {
expect(
Frontend.decodeRequest({
jsonrpc: "2.0",
id: 1,
method: "ui.click",
params: {
target: 12,
x: 4,
y: 0,
semantic: {
id: "session.permission.action.once",
instance: "permission-1",
element: 12,
},
},
}),
).toMatchObject({
method: "ui.click",
params: {
semantic: {
id: "session.permission.action.once",
instance: "permission-1",
element: 12,
},
},
})
})
test("decodes semantic UI snapshots", () => {
expect(
Frontend.decodeRequest({
jsonrpc: "2.0",
id: 1,
method: "ui.snapshot",
}),
).toMatchObject({ method: "ui.snapshot" })
const decode = Schema.decodeUnknownSync(Frontend.SemanticSnapshot)
expect(
decode({
format: "opencode-ui-snapshot-v1",
nodes: [
{
id: "session.permission",
role: "dialog",
label: "Permission required",
element: 1,
expanded: false,
},
],
}),
).toMatchObject({ nodes: [{ role: "dialog", expanded: false }] })
expect(() =>
decode({
format: "opencode-ui-snapshot-v1",
nodes: [{ id: "", role: "dialog", element: 0 }],
}),
).toThrow()
for (const nodes of [
[
{ id: "duplicate", role: "dialog", element: 1 },
{ id: "duplicate", role: "option", element: 2 },
],
[
{ id: "first", role: "dialog", element: 1 },
{ id: "second", role: "option", element: 1 },
],
[{ id: "orphan", parent: "missing", role: "option", element: 1 }],
[
{ id: "first", parent: "second", role: "dialog", element: 1 },
{ id: "second", parent: "first", role: "option", element: 2 },
],
])
expect(() => decode({ format: "opencode-ui-snapshot-v1", nodes })).toThrow()
})
test("decodes the simulated tool lifecycle", () => {
const registration = {
name: "lookup",
description: "Look up a value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
outputSchema: { type: "object" },
permission: "lookup",
options: { codemode: false },
}
expect(
Backend.decodeRequest({
jsonrpc: "2.0",
id: 1,
method: "tool.attach",
params: { tools: [registration] },
}),
).toMatchObject({ method: "tool.attach", params: { tools: [registration] } })
expect(
Backend.decodeRequest({
jsonrpc: "2.0",
id: 2,
method: "tool.update",
params: {
id: "tool_1",
sequence: 0,
update: { structured: { phase: "searching" }, content: [{ type: "text", text: "Searching" }] },
},
}),
).toMatchObject({ method: "tool.update" })
expect(
Backend.decodeRequest({
jsonrpc: "2.0",
id: 3,
method: "tool.finish",
params: {
id: "tool_1",
output: { structured: { answer: 42 }, content: [{ type: "text", text: "42" }] },
},
}),
).toMatchObject({ method: "tool.finish" })
expect(
Backend.decodeRequest({
jsonrpc: "2.0",
id: 4,
method: "tool.fail",
params: { id: "tool_2", message: "lookup failed" },
}),
).toMatchObject({ method: "tool.fail" })
expect(() =>
Backend.decodeRequest({
jsonrpc: "2.0",
id: 5,
method: "tool.attach",
params: { tools: [registration, registration] },
}),
).toThrow()
for (const invalid of [
{ ...registration, name: "1lookup" },
{ ...registration, options: { namespace: "bad group", codemode: false } },
{ ...registration, name: "execute", options: { codemode: false } },
{ ...registration, options: { namespace: "a".repeat(64), codemode: false } },
])
expect(() =>
Backend.decodeRequest({
jsonrpc: "2.0",
id: 6,
method: "tool.attach",
params: { tools: [invalid] },
}),
).toThrow()
expect(() =>
Backend.decodeRequest({
jsonrpc: "2.0",
id: 7,
method: "tool.attach",
params: {
tools: [
{ ...registration, name: "b_c", options: { namespace: "a", codemode: false } },
{ ...registration, name: "c", options: { namespace: "a.b", codemode: false } },
],
},
}),
).toThrow()
})
const params: Handshake.Params = {
client: { name: "opencode-drive", version: "test" },
expectedRole: "ui",
@@ -1,5 +1,24 @@
import { expect, test } from "bun:test"
import { Deferred, Effect, Fiber, Queue, Stream } from "effect"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Deferred, Effect, Fiber, Layer, Queue, Stream } from "effect"
import type { Scope } from "effect/Scope"
import { SimulatedProvider } from "../src/backend/simulated-provider"
import { availableEndpoint, connect } from "./fixture/websocket"
@@ -125,7 +144,7 @@ test("replaces the previous attached controller", async () => {
)
expect(yield* Queue.take(secondMessages)).toMatchObject({ id: 2, result: { ok: true } })
expect(Array.from(yield* Fiber.join(response))).toEqual([{ type: "finish", reason: "stop" }])
}).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped),
}).pipe(Effect.provide(providerLayer(endpoint)), Effect.scoped),
)
})
@@ -194,6 +213,454 @@ test("fails the provider stream when Drive disconnects the invocation", async ()
)
})
test("controls arbitrary tools through scoped SDK overlays", async () => {
const endpoint = availableEndpoint()
const directory = await mkdtemp(join(tmpdir(), "opencode-simulated-tools-"))
const secondDirectory = join(directory, "second")
await mkdir(secondDirectory)
try {
await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* connect(endpoint)
const messages = yield* messagesFrom(socket)
const plugins = yield* SdkPlugins.Service
let activations = 0
yield* plugins.register(
Plugin.define({
id: "opencode.simulation.test.activation-count",
effect: () => Effect.sync(() => void activations++),
}),
)
const registration = {
name: "lookup",
description: "Look up a value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
outputSchema: { type: "object" },
options: { codemode: false },
}
const locations = yield* LocationServiceMap.Service
const [primary, secondary] = yield* Effect.all([
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(secondDirectory) }))),
])
yield* Effect.forEach([primary, secondary], (context) =>
PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)),
)
expect(activations).toBe(2)
yield* Effect.gen(function* () {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tool.attach",
params: { tools: [registration] },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } })
const registry = yield* ToolRegistry.Service
const materialized = yield* registry.materialize()
expect(materialized.definitions).toContainEqual(
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const secondaryMaterialized = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.materialize(),
).pipe(Effect.provide(secondary))
expect(secondaryMaterialized.definitions).toContainEqual(
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const progress: ToolRegistry.Progress[] = []
const settle = (callID: string, query: string) =>
materialized.settle({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
progress: (update) => Effect.sync(() => progress.push(update)),
call: {
type: "tool-call",
id: callID,
name: "lookup",
input: { query },
},
})
const successful = yield* settle("call_success", "answer").pipe(Effect.forkScoped)
const successInvocation = yield* takeToolInvocation(messages)
expect(successInvocation.params).toMatchObject({
name: "lookup",
input: { query: "answer" },
context: {
sessionID: "ses_simulated_tools",
agent: "build",
messageID: "msg_simulated_tools",
callID: "call_success",
},
})
const successID = requireString(requireRecord(successInvocation.params).id)
const update = JSON.stringify({
jsonrpc: "2.0",
id: 20,
method: "tool.update",
params: {
id: successID,
sequence: 0,
update: {
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
},
},
})
socket.send(update)
expect(yield* Queue.take(messages)).toMatchObject({ id: 20, result: { ok: true } })
socket.send(update)
expect(yield* Queue.take(messages)).toMatchObject({ id: 20, result: { ok: true } })
socket.send(
JSON.stringify({
...JSON.parse(update),
id: 21,
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 21, result: { ok: true } })
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 20,
method: "tool.update",
params: {
id: successID,
sequence: 0,
update: { structured: { phase: "different" } },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({
id: 20,
error: { message: expect.stringContaining("reused with different progress") },
})
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 22,
method: "tool.update",
params: {
id: successID,
sequence: 2,
update: { structured: { phase: "skipped" } },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({
id: 22,
error: { message: expect.stringContaining("Expected simulated tool update sequence 1") },
})
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 3,
method: "tool.finish",
params: {
id: successID,
output: {
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
},
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 3, result: { ok: true } })
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 23,
method: "tool.finish",
params: {
id: successID,
output: {
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
},
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 23, result: { ok: true } })
expect(yield* Fiber.join(successful)).toMatchObject({
result: { type: "text", value: "42" },
output: {
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
},
})
expect(progress).toEqual([
{
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
},
])
const failed = yield* settle("call_failure", "missing").pipe(Effect.forkScoped)
const failedInvocation = yield* takeToolInvocation(messages)
const failedID = requireString(requireRecord(failedInvocation.params).id)
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 4,
method: "tool.fail",
params: { id: failedID, message: "lookup failed" },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { ok: true } })
expect(yield* Fiber.join(failed)).toMatchObject({
result: { type: "error", value: "lookup failed" },
})
const concurrent = [
yield* settle("call_first", "first").pipe(Effect.forkScoped),
yield* settle("call_second", "second").pipe(Effect.forkScoped),
]
const invocations = [yield* takeToolInvocation(messages), yield* takeToolInvocation(messages)]
const byCall = new Map(
invocations.map((invocation) => {
const params = requireRecord(invocation.params)
const context = requireRecord(params.context)
return [requireString(context.callID), requireString(params.id)]
}),
)
for (const [id, callID, value] of [
[5, "call_second", "second result"],
[6, "call_first", "first result"],
] as const) {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id,
method: "tool.finish",
params: {
id: byCall.get(callID),
output: { structured: value, content: [{ type: "text", text: value }] },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
}
expect((yield* Fiber.join(concurrent[0])).result).toEqual({ type: "text", value: "first result" })
expect((yield* Fiber.join(concurrent[1])).result).toEqual({ type: "text", value: "second result" })
const cancelled = yield* settle("call_cancelled", "slow").pipe(Effect.forkScoped)
const cancelledInvocation = yield* takeToolInvocation(messages)
const cancelledID = requireString(requireRecord(cancelledInvocation.params).id)
yield* Fiber.interrupt(cancelled)
expect(yield* Queue.take(messages)).toMatchObject({
method: "tool.cancel",
params: { id: cancelledID, reason: "interrupted" },
})
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 7,
method: "tool.finish",
params: {
id: cancelledID,
output: { structured: null, content: [] },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({
id: 7,
error: { message: expect.stringContaining("not found or already finished") },
})
const replayed = yield* settle("call_replayed", "reconnect").pipe(Effect.forkScoped)
const original = yield* takeToolInvocation(messages)
const originalID = requireString(requireRecord(original.params).id)
const replayedProgress = {
structured: { phase: "before-reconnect" },
content: [{ type: "text", text: "Still running" }],
}
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 25,
method: "tool.update",
params: { id: originalID, sequence: 0, update: replayedProgress },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 25, result: { ok: true } })
const replacement = yield* connect(endpoint)
const replacementMessages = yield* messagesFrom(replacement)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 8,
method: "tool.attach",
params: { tools: [registration] },
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({
id: 8,
error: { message: expect.stringContaining("already attached") },
})
yield* closeSocket(socket)
const disconnected = yield* registry.materialize()
expect(disconnected.definitions).toContainEqual(expect.objectContaining({ name: "lookup" }))
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 90,
method: "tool.attach",
params: { tools: [{ ...registration, name: "replacement" }] },
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({
id: 90,
error: { message: expect.stringContaining("must settle pending invocations") },
})
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 9,
method: "tool.attach",
params: { tools: [registration] },
}),
)
const attached = [
requireRecord(yield* Queue.take(replacementMessages)),
requireRecord(yield* Queue.take(replacementMessages)),
]
expect(attached).toContainEqual(expect.objectContaining({ id: 9, result: { attached: true } }))
expect(attached).toContainEqual(
expect.objectContaining({
method: "tool.invocation",
params: expect.objectContaining({ id: originalID }),
}),
)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 26,
method: "tool.update",
params: { id: originalID, sequence: 0, update: replayedProgress },
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 26, result: { ok: true } })
expect(progress.filter((update) => update.structured.phase === "before-reconnect")).toHaveLength(1)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 10,
method: "tool.finish",
params: {
id: originalID,
output: {
structured: "replayed result",
content: [{ type: "text", text: "replayed result" }],
},
},
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 10, result: { ok: true } })
expect((yield* Fiber.join(replayed)).result).toEqual({
type: "text",
value: "replayed result",
})
const preserved = yield* settle("call_preserved", "same generation").pipe(Effect.forkScoped)
const preservedInvocation = yield* takeToolInvocation(replacementMessages)
const preservedID = requireString(requireRecord(preservedInvocation.params).id)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 27,
method: "tool.finish",
params: {
id: preservedID,
output: { structured: "preserved", content: [{ type: "text", text: "preserved" }] },
},
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 27, result: { ok: true } })
expect((yield* Fiber.join(preserved)).result).toEqual({ type: "text", value: "preserved" })
const namespaced = [
{ ...registration, name: "search", options: { namespace: "github", codemode: false } },
{ ...registration, name: "search", options: { namespace: "web", codemode: false } },
]
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 11,
method: "tool.attach",
params: { tools: namespaced },
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 11, result: { attached: true } })
const replaced = yield* registry.materialize()
const replacedNames = replaced.definitions.map((definition) => definition.name)
expect(replacedNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
expect(replacedNames).not.toContain("lookup")
const secondaryReplaced = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.materialize(),
).pipe(Effect.provide(secondary))
const secondaryNames = secondaryReplaced.definitions.map((definition) => definition.name)
expect(secondaryNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
expect(secondaryNames).not.toContain("lookup")
const routed = yield* replaced
.settle({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
call: {
type: "tool-call",
id: "call_namespaced",
name: "github_search",
input: { query: "routing" },
},
})
.pipe(Effect.forkScoped)
const routedInvocation = yield* takeToolInvocation(replacementMessages)
expect(routedInvocation.params).toMatchObject({ name: "github_search" })
const routedID = requireString(requireRecord(routedInvocation.params).id)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 12,
method: "tool.finish",
params: {
id: routedID,
output: { structured: "routed", content: [{ type: "text", text: "routed" }] },
},
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 12, result: { ok: true } })
expect((yield* Fiber.join(routed)).result).toEqual({ type: "text", value: "routed" })
expect(
yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
call: {
type: "tool-call",
id: "call_stale",
name: "lookup",
input: { query: "stale" },
},
}),
).toMatchObject({
result: { type: "error", value: expect.stringContaining("no longer active") },
})
expect(activations).toBe(2)
}).pipe(Effect.provide(primary))
}).pipe(Effect.provide(toolLifecycleLayer(endpoint)), Effect.scoped),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
}, 15_000)
const request: SimulatedProvider.ProviderRequest = {
url: "https://api.openai.com/v1/chat/completions",
body: { model: "gpt-5", messages: [{ role: "user", content: "Hello" }] },
@@ -213,7 +680,26 @@ function runProvider<E>(
const socket = yield* connect(endpoint)
const messages = yield* messagesFrom(socket)
yield* body(provider, socket, messages)
}).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped),
}).pipe(Effect.provide(providerLayer(endpoint)), Effect.scoped),
)
}
const providerLayer = (endpoint: string) =>
SimulatedProvider.layerDrive({ endpoint }).pipe(
Layer.provide(
Layer.succeed(SdkPlugins.Service, SdkPlugins.Service.of({ register: () => Effect.void, all: () => [] })),
),
)
const toolLifecycleLayer = (endpoint: string) => {
const provider = makeGlobalNode({
service: SimulatedProvider.Service,
layer: SimulatedProvider.layerDrive({ endpoint }),
deps: [SdkPlugins.node],
})
return AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node, provider]),
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
)
}
@@ -227,6 +713,19 @@ function messagesFrom(socket: WebSocket) {
})
}
function closeSocket(socket: WebSocket) {
return Effect.callback<void>((resume) => {
if (socket.readyState === WebSocket.CLOSED) {
resume(Effect.void)
return Effect.void
}
const closed = () => resume(Effect.void)
socket.addEventListener("close", closed, { once: true })
socket.close()
return Effect.sync(() => socket.removeEventListener("close", closed))
})
}
function attach(socket: WebSocket, messages: Queue.Queue<unknown>) {
return Effect.gen(function* () {
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" }))
@@ -244,6 +743,21 @@ function takeInvocation(messages: Queue.Queue<unknown>) {
)
}
function takeToolInvocation(messages: Queue.Queue<unknown>) {
return Queue.take(messages).pipe(
Effect.map((message) => {
const opened = requireRecord(message)
if (opened.method !== "tool.invocation") throw new Error("Expected a tool.invocation notification")
return opened
}),
)
}
function requireString(value: unknown) {
if (typeof value !== "string") throw new Error("Expected a string")
return value
}
function requireRecord(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new Error("Expected an object")
return value
+2
View File
@@ -23,6 +23,7 @@
"./context/runtime": "./src/context/runtime.tsx",
"./context/client": "./src/context/client.tsx",
"./context/theme": "./src/context/theme.tsx",
"./theme/discovery": "./src/theme/discovery.ts",
"./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
"./attention": "./src/attention.ts",
@@ -30,6 +31,7 @@
"./editor-zed": "./src/editor-zed.ts",
"./mini": "./src/mini/index.ts",
"./mini/tool": "./src/mini/tool.ts",
"./model-preference": "./src/model-preference.ts",
"./runtime": "./src/runtime.tsx",
"./terminal-win32": "./src/terminal-win32.ts",
"./context/keymap": "./src/context/keymap.tsx",
+2 -1
View File
@@ -420,7 +420,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
const client = useClient()
const toast = useToast()
const themeState = useTheme()
const { themeV2, mode, setMode, locked, lock, unlock } = themeState
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
const data = useData()
const location = useLocation()
const exit = useExit()
@@ -818,6 +818,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "theme.switch_mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
palette: undefined,
enabled: () => supports(mode() === "dark" ? "light" : "dark"),
run: () => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -4,8 +4,10 @@ import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
export function DevToolsSidebar() {
const { themeV2, mode, setMode } = useTheme().contextual("elevated")
const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated")
const [modeHovered, setModeHovered] = createSignal(false)
const nextMode = () => (mode() === "dark" ? "light" : "dark")
const canSwitchMode = () => supports(nextMode())
return (
<box
@@ -29,12 +31,12 @@ export function DevToolsSidebar() {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={modeHovered() ? themeV2.background.action("hovered") : undefined}
onMouseOver={() => setModeHovered(true)}
backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action("hovered") : undefined}
onMouseOver={() => setModeHovered(canSwitchMode())}
onMouseOut={() => setModeHovered(false)}
onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}
onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined}
>
<text fg={themeV2.text()}>{mode()}</text>
<text fg={canSwitchMode() ? themeV2.text() : themeV2.text.subdued()}>{mode()}</text>
</box>
</box>
</box>
+60 -71
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
@@ -12,73 +13,35 @@ import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import type { ComponentTheme } from "../theme/v2/component"
// Sort by how much attention a server needs: auth prompts first, then failures,
// then healthy servers, and intentionally-off servers last.
function statusMeta(status: McpServer["status"], themeV2: ComponentTheme) {
switch (status.status) {
case "needs_auth":
return {
rank: 0,
icon: "!",
label: "Needs authentication",
color: themeV2.text.feedback.warning(),
error: undefined,
bold: false,
}
case "needs_client_registration":
return {
rank: 1,
icon: "✗",
label: "Needs registration",
color: themeV2.text.feedback.error(),
error: status.error,
bold: false,
}
case "failed":
return {
rank: 2,
icon: "✗",
label: "Failed",
color: themeV2.text.feedback.error(),
error: status.error,
bold: false,
}
case "connected":
return {
rank: 3,
icon: "✓",
label: "Connected",
color: themeV2.text.feedback.success(),
error: undefined,
bold: true,
}
case "pending":
return { rank: 4, icon: "◌", label: "Pending", color: themeV2.text.subdued(), error: undefined, bold: false }
default:
return { rank: 5, icon: "○", label: "Disabled", color: themeV2.text.subdued(), error: undefined, bold: false }
function statusError(status: McpServer["status"]) {
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
return undefined
}
function Status(props: { enabled: boolean; loading: boolean }) {
const { themeV2 } = useTheme().contextual("elevated")
if (props.loading) return <span style={{ fg: themeV2.text.subdued() }}>⋯ Loading</span>
if (props.enabled) {
return <span style={{ fg: themeV2.text.feedback.success(), attributes: TextAttributes.BOLD }}>✓ Enabled</span>
}
return <span style={{ fg: themeV2.text.subdued() }}>○ Disabled</span>
}
export function DialogMcp() {
const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
onMount(() => {
dialog.setSize("large")
})
const [loading, setLoading] = createSignal<string | null>(null)
const servers = createMemo(() =>
pipe(
data.location.mcp.server.list() ?? [],
sortBy(
(server) => statusMeta(server.status, themeV2).rank,
(server) => server.name,
),
sortBy((server) => server.name),
),
)
@@ -88,33 +51,41 @@ export function DialogMcp() {
if (first) setFocused(first.name)
})
const options = createMemo(() =>
servers().map((server) => {
const meta = statusMeta(server.status, themeV2)
return {
value: server.name,
title: server.name,
footer: (
<span style={{ fg: meta.color, attributes: meta.bold ? TextAttributes.BOLD : undefined }}>
{meta.icon} {meta.label}
</span>
),
}
}),
)
const options = createMemo(() => {
const loadingMcp = loading()
return servers().map((server) => ({
value: server.name,
title: server.name,
description: server.status.status,
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
}))
})
const focusedError = createMemo(() => {
const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusMeta(server.status, themeV2).error : undefined
return server ? statusError(server.status) : undefined
})
const open = (name: string | undefined) => {
const server = servers().find((entry) => entry.name === name)
if (!server || !statusMeta(server.status, themeV2).error) return
if (!server || !statusError(server.status)) return
setDetail(server)
}
// Connected servers disconnect; everything else (disabled, failed, needs_auth) retries a
// connection. The mcp.status.changed event refreshes the list, so no manual sync is needed.
const toggle = (name: string) => {
if (loading() !== null) return
const server = servers().find((entry) => entry.name === name)
if (!server || server.status.status === "pending") return
setLoading(name)
const current = data.location.default()
const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } }
const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input)
void call.catch(toast.error).finally(() => setLoading(null))
}
return (
<box>
<Show
@@ -127,6 +98,16 @@ export function DialogMcp() {
preserveSelection
onMove={(option) => setFocused(option.value as string)}
onSelect={(option) => open(option.value as string)}
actions={[
{
title: "toggle",
command: "dialog.mcp.toggle",
onTrigger: (option) => {
setFocused(option.value as string)
toggle(option.value as string)
},
},
]}
footer={
<Show when={focusedError()}>
<text fg={themeV2.text.subdued()}>enter to view error</text>
@@ -135,7 +116,15 @@ export function DialogMcp() {
/>
}
>
{(server) => <DialogMcpError server={server()} onBack={() => setDetail()} />}
{(server) => (
<DialogMcpError
server={server()}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
)
@@ -150,7 +139,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusMeta(props.server.status, themeV2).error ?? "Unknown MCP connection error"
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
@@ -22,38 +22,7 @@ import { Keymap } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
import { stringWidth } from "../../util/string-width"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
return hashIndex !== -1 ? input.substring(0, hashIndex) : input
}
function extractLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
if (hashIndex === -1) {
return { baseQuery: input }
}
const baseName = input.substring(0, hashIndex)
const linePart = input.substring(hashIndex + 1)
const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
if (!lineMatch) {
return { baseQuery: baseName }
}
const startLine = Number(lineMatch[1])
const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
return {
lineRange: {
baseName,
startLine,
endLine,
},
baseQuery: baseName,
}
}
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
export type AutocompleteRef = {
onInput: (value: string) => void
@@ -275,9 +244,9 @@ export function Autocomplete(props: {
const referenceMatch = createMemo(() => {
if (!store.visible || store.visible === "/") return
const { baseQuery } = extractLineRange(search())
const slash = baseQuery.indexOf("/")
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
const base = parseFileLineRange(search()).base
const slash = base.indexOf("/")
const alias = slash === -1 ? base : base.slice(0, slash)
return references().find((item) => !item.hidden && item.name === alias)
})
@@ -312,11 +281,11 @@ export function Autocomplete(props: {
async (input) => {
if (!input.visible || input.visible === "/") return { options: [], failed: false }
if (referenceMatch()) return { options: [], failed: false }
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
const { lineRange, base } = parseFileLineRange(input.query ?? "")
const result = await client.api.file
.find({
query: baseQuery,
query: base,
limit: 20,
location: {
directory: input.location?.directory,
@@ -500,9 +469,9 @@ export function Autocomplete(props: {
}
const fuzziedNonFiles = fuzzysort
.go(removeLineRange(searchValue), nonFileOptions, {
.go(stripFileLineRange(searchValue), nonFileOptions, {
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
(obj) => stripFileLineRange((obj.value ?? obj.display).trimEnd()),
// Match description for slash commands only; for "@" it surfaced unrelated items.
...(store.visible === "/" ? ["description" as const] : []),
(obj) => obj.aliases?.join(" ") ?? "",
+11 -26
View File
@@ -26,6 +26,8 @@ import { editorSelectionKey, useEditorContext, type EditorSelection } from "../.
import { normalizePromptContent, openEditor } from "../../editor"
import { useExit } from "../../context/exit"
import { promptOffsetWidth } from "../../prompt/display"
import { expandPromptInputPastedText, realignPromptInputMentions } from "../../prompt/mention"
import { parseSlashHead } from "../../prompt/parse"
import { stringWidth } from "../../util/string-width"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
@@ -137,15 +139,15 @@ function formatEditorContext(selection: EditorSelection) {
let stashed: { prompt: PromptInfo; cursor: number } | undefined
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
if (!input.startsWith("/")) return
const separator = input.search(/\s/)
const name = input.slice(1, separator === -1 ? undefined : separator)
const head = parseSlashHead(input, /\s/)
if (!head) return
const command = commands.find(
(command) =>
command.slash?.arguments && (command.slash.name === name || command.slash.aliases?.includes(name) === true),
command.slash?.arguments &&
(command.slash.name === head.name || command.slash.aliases?.includes(head.name) === true),
)
if (!command) return
return { command, input: separator === -1 ? "" : input.slice(separator + 1) }
return { command, input: head.arguments }
}
export function Prompt(props: PromptProps) {
@@ -493,13 +495,8 @@ export function Prompt(props: PromptProps) {
run: async () => {
dialog.clear()
// replace summarized text parts with the actual text
const text = store.prompt.pasted.reduce(
(result, part) => result.replace(part.source.text, part.text),
store.prompt.text,
)
const value = text
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
const value = editorPrompt.text
const content = await openEditor({
renderer,
value,
@@ -513,20 +510,8 @@ export function Prompt(props: PromptProps) {
input.setText(normalized)
// Update attachment positions and drop virtual text deleted in the editor.
// this handles a case where the user edits the text in the editor
// such that the virtual text moves around or is deleted
const moveMention = <Part extends { mention?: { start: number; end: number; text: string } }>(part: Part) => {
if (!part.mention?.text) return part
const start = normalized.indexOf(part.mention.text)
if (start === -1) return
return { ...part, mention: { ...part.mention, start, end: start + part.mention.text.length } }
}
setStore("prompt", {
text: normalized,
files: store.prompt.files?.map(moveMention).filter((part) => part !== undefined),
agents: store.prompt.agents?.map(moveMention).filter((part) => part !== undefined),
...realignPromptInputMentions(normalized, editorPrompt),
pasted: [],
})
restoreExtmarksFromPrompt(store.prompt)
@@ -1122,7 +1107,7 @@ export function Prompt(props: PromptProps) {
if (
session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selectedModel.modelID ||
session.model.variant !== variant
(session.model.variant ?? "default") !== (variant ?? "default")
) {
await client.api.session.switchModel({
sessionID,
+3 -2
View File
@@ -123,8 +123,8 @@ export const Definitions = {
mcp_list: keybind("none", "List MCP servers"),
provider_connect: keybind("none", "Connect integration"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
agent_cycle: keybind("shift+tab", "Next agent"),
agent_cycle_reverse: keybind("none", "Previous agent"),
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
variant_list: keybind("none", "List model variants"),
@@ -213,6 +213,7 @@ export const Definitions = {
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
+1
View File
@@ -1294,6 +1294,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
project: location.project.id,
limit: 50,
order: "desc",
parentID: null,
})
})
.then((response) => {

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