mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-23 09:08:56 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1de1c51240 | |||
| 3da0dea8e7 | |||
| c7aa47c144 | |||
| 7be95bcd6a | |||
| 3f4fb3f9db | |||
| 6702ce0d3f | |||
| a9895a1e8a | |||
| 75c7ac6a2c | |||
| 0eb71d0fc7 | |||
| deee40c572 | |||
| 8b5655ed53 | |||
| b9525b5878 | |||
| 2b8d1998d6 | |||
| 391cfbcfe7 | |||
| fe0c74f4df | |||
| 6d32bc9cb0 | |||
| 925c2423de | |||
| 71cb419570 | |||
| edc93ceff1 | |||
| 86a468c4d8 | |||
| f05d2ab551 | |||
| bef6cfbffe | |||
| fe9a936867 | |||
| d5669ca934 | |||
| 04f0a771a3 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
+50
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+55
File diff suppressed because one or more lines are too long
@@ -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"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }] : []
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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> }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]]]`,
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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:"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(" ") ?? "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user