Compare commits

..

2 Commits

Author SHA1 Message Date
neriousy d372c72ccd refactor(core): scope skill bypass to tool context 2026-08-21 13:44:17 +00:00
neriousy 9a3768522f fix(core): allow explicitly referenced skills 2026-08-21 13:30:17 +00:00
47 changed files with 473 additions and 1269 deletions
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8pRvkbUX2aZhFTFtFuUM6mPqZZhfC4mFd1+BXVMzEJk=",
"aarch64-linux": "sha256-df25TWdjjLKeLZJEfrDpgVaV8ZAZhHWPoV2IPIQ4U2w=",
"aarch64-darwin": "sha256-VjbOx7Zi9eTiPxqpKN3+EQWweBfJHf7y36sGSN1peg0=",
"x86_64-darwin": "sha256-q7nW4AR2OnepnDcPDtYECgcsXI+JRHOCWhPsAX8t7q0="
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
}
}
@@ -22,8 +22,6 @@ export interface Options {
readonly id: string
readonly name: string
readonly rotateAfterMs?: number
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
@@ -149,19 +147,18 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel =
input.webSocket && (options.enabled?.(parts.url) ?? true)
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
const channel = input.webSocket
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
@@ -11,7 +11,7 @@ import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses"
@@ -247,16 +247,12 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const channelTransport = (options: Omit<Options, "driver">) =>
OpenResponsesChannel.transport<OpenAIResponsesBody>({
...options,
driver: (input) => OpenAIResponsesChannel.driver({ id: options.id, name: options.name, ...input }),
})
export const transport = channelTransport({
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
id: ADAPTER,
name: NAME,
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
})
export const route = Route.make({
-26
View File
@@ -1,4 +1,3 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
@@ -11,7 +10,6 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("azure")
const routeAuth = Auth.remove("authorization")
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
// Azure needs the customer's resource URL; supply either `resourceName`
// (helper builds the URL) or `baseURL` directly.
@@ -42,30 +40,6 @@ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
transport: OpenAIResponses.channelTransport({
id: "azure-openai-responses",
name: "Azure OpenAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
enabled: (value) => {
const url = new URL(value)
return (
url.protocol === "https:" &&
url.hostname.endsWith(".openai.azure.com") &&
url.pathname.endsWith("/openai/v1/responses") &&
url.searchParams.get("api-version") === "v1"
)
},
url: (value) => {
const url = new URL(value)
url.searchParams.delete("api-version")
return url.toString()
},
headers: (headers) => {
const apiKey = headers["api-key"]
if (!apiKey) return headers
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
},
}),
})
const chatRoute = OpenAIChat.route.with({
+1 -7
View File
@@ -28,19 +28,13 @@ export interface Settings extends ProviderPackage.Settings {
export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.channelTransport({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { store: false } },
})
@@ -691,134 +691,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://api.x.ai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(24 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe("Bearer test")
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "grok-4.5",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_xai" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_xai" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
expect(response.finishReason.normalized).toBe("stop")
}),
)
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const cases = [
{
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
authorization: "Bearer azure-key",
},
{
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
"deployment",
),
authorization: "Bearer entra-token",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe(item.authorization)
expect(exchange.connect.headers["api-key"]).toBeUndefined()
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "deployment",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))),
)
}),
)
it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () =>
Effect.gen(function* () {
const cases = [
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
apiVersion: "2025-04-01-preview",
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview",
},
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
useDeploymentBasedUrls: true,
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1",
},
{
model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses(
"deployment",
),
url: "https://gateway.example/azure/responses",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(input.request.url).toBe(item.url)
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
}),
)
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
@@ -5,11 +5,9 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
const gate = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
@@ -23,7 +21,6 @@ test("applies message latency after a list response gate is released", async ()
messageDelay: 25,
beforeMessagesResponse: () => {
events.push("before")
started.resolve()
return gate.promise
},
onMessages: (request) => events.push(request.phase),
@@ -34,18 +31,12 @@ test("applies message latency after a list response gate is released", async ()
})
const response = handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/session/session/message",
method: () => "GET",
headers: () => ({}),
postDataBuffer: () => null,
}),
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
},
} as unknown as Route)
await started.promise
expect(events).toEqual(["start", "before"])
const released = performance.now()
@@ -54,42 +45,3 @@ test("applies message latency after a list response gate is released", async ()
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
})
test("routes requests through the HttpApi contract", async () => {
const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
onConnectKey: connected.resolve,
})
const body = Buffer.from(JSON.stringify({ key: "secret" }))
let status: number | undefined
await handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key",
method: () => "POST",
headers: () => ({ "content-type": "application/json" }),
postDataBuffer: () => body,
}),
fulfill: (response: Parameters<Route["fulfill"]>[0]) => {
status = response?.status
return Promise.resolve()
},
} as unknown as Route)
expect(status).toBe(204)
expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } })
})
-249
View File
@@ -1,249 +0,0 @@
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
const Json = Schema.Json.pipe(
Schema.decodeTo(Schema.Unknown, {
decode: SchemaGetter.passthrough(),
encode: SchemaGetter.transform(jsonValue),
}),
HttpApiSchema.asJson(),
)
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
parentID: Schema.optional(Schema.String),
search: Schema.optional(Schema.String),
order: Schema.optional(Schema.String),
cursor: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
path: Schema.optional(Schema.String),
query: Schema.optional(Schema.String),
type: Schema.optional(Schema.String),
})
const SessionParams = { sessionID: Schema.String }
const NoContent = HttpApiSchema.NoContent
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
message: Schema.String,
}) {}
export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBadRequest", {
message: Schema.String,
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
.add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json }))
.add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json }))
.add(
HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", {
params: { integrationID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", {
params: { integrationID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
params: { credentialID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("command", "/api/command", { success: Json }))
.add(HttpApiEndpoint.get("skill", "/api/skill", { success: Json }))
.add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json }))
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
HttpApiEndpoint.get("fsRead", "/api/fs/read/*", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
)
.add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json }))
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
.add(
HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionList", "/api/session", {
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
.add(
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
params: SessionParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", {
params: { ...SessionParams, formID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", {
params: { ...SessionParams, permissionID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertStage", "/api/session/:sessionID/revert/stage", {
params: SessionParams,
payload: JsonPayload,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(
HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", {
params: { ...SessionParams, messageID: Schema.String },
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", {
params: SessionParams,
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
export const MockApi = HttpApi.make("mock").add(Group)
function jsonValue(value: unknown): Schema.Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map(jsonValue)
if (!value || typeof value !== "object") return null
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])),
)
}
+320 -317
View File
@@ -1,9 +1,5 @@
import type { Page } from "@playwright/test"
import type { Page, Route } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Duration, Effect, Layer } from "effect"
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
provider: unknown | (() => unknown)
@@ -43,8 +39,9 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
const cursors = new Map<string, string>()
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
let nextCursor = 0
await page.addInitScript(
({ server, retry }) => {
@@ -131,331 +128,316 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50)
page.on("close", () => clearInterval(timer))
}
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
page.on("close", () => void transport.dispose())
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.origin !== server && url.port !== appPort) return route.fallback()
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
const body = route.request().postDataBuffer()
const response = await transport.handler(
new Request(url, {
method: route.request().method(),
headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
if (response.status === 404 && url.origin !== server) return route.fallback()
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...corsHeaders },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
const events = config.events?.()
const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n`
const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" }))
})
.handleRaw("fsRead", (ctx) =>
Effect.gen(function* () {
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
}),
const path = url.pathname
if (path === "/api/event") {
const events = config.events?.()
return sse(
route,
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
config.eventRetry,
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
reference: () =>
Effect.succeed({
location: {
directory: config.directory,
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
},
},
data: [],
}),
agent: () =>
Effect.succeed({
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }),
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
modelDefault: () =>
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
integrationGet: (ctx) =>
Effect.succeed({
location: location(config),
data: {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
connections: [],
},
}),
integrationConnect: (ctx) =>
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: () => Effect.succeed({ location: location(config), data: [] }),
skill: () => Effect.succeed({ location: location(config), data: [] }),
plugin: () => Effect.succeed({ location: location(config), data: [] }),
mcp: () => Effect.succeed({ location: location(config), data: [] }),
mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }),
projectList: () => {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectCurrent: () =>
Effect.succeed({
}
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/api/reference")
return json(route, {
location: {
directory: config.directory,
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
}),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
]),
worktreeCreate: (ctx) => {
const input = record(ctx.payload) ? ctx.payload : {}
return Effect.succeed({
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
typeof input.name === "string" ? input.name : "copy"
}`,
})
},
},
worktreeRemove: () => noContent,
worktreeRefresh: () => noContent,
location: () => Effect.succeed(location(config)),
permissionRequests: () =>
Effect.succeed({
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
}),
formRequests: () =>
Effect.succeed({
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
}),
vcs: () =>
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
fsList: (ctx) =>
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
Effect.map((data) => ({ location: location(config), data })),
),
fsFind: (ctx) =>
Effect.promise(() =>
Promise.resolve(
config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }),
),
).pipe(
Effect.map((entries) => ({
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})),
),
shell: () => Effect.succeed({ location: location(config), data: [] }),
ptyConnectToken: () =>
Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }),
sessionList: (ctx) => {
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return (
!ctx.query.directory ||
location?.directory === ctx.query.directory ||
session.directory === ctx.query.directory
)
})
.filter((session) => {
if (ctx.query.parentID === undefined) return true
if (ctx.query.parentID === "null") return session.parentID === undefined
return session.parentID === ctx.query.parentID
})
.filter((session) =>
ctx.query.search === undefined
? true
: String(session.title ?? "")
.toLowerCase()
.includes(ctx.query.search.toLowerCase()),
data: [],
})
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/provider")
return json(route, {
location: location(config),
data: currentProviders(providerConfig(config)),
})
if (path === "/api/model")
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: {
id: integration,
name: integration,
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
connections: [],
},
})
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
if (path === "/api/project/current")
return json(route, {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
})
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config))
if (worktree && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
return json(route, {
location: location(config),
data: await config.fileList(url.searchParams.get("path") ?? ""),
})
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
}
if (path === "/api/fs/find" && config.findFiles) {
const entries = await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("type") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
})
return json(route, {
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed()
const offset = Number(ctx.query.cursor ?? 0)
const limit = ctx.query.limit ?? 50
const data = ordered.slice(offset, offset + limit)
return Effect.succeed({
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
})
},
sessionCreate: (ctx) => {
const payload = record(ctx.payload) ? ctx.payload : {}
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
: entries,
})
}
if (path === "/api/shell" && route.request().method() === "GET")
return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (path === "/api/session") {
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
)
config.sessions.push(created)
return json(route, { data: created })
}
if (route.request().method() !== "GET") return route.fallback()
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => {
if (parentID === null) return true
if (parentID === "null") return session.parentID === undefined
return session.parentID === parentID
})
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created }))
},
sessionActive: () => {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return Effect.succeed({
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
},
sessionGet: (ctx) => {
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
return session
? Effect.succeed({ data: currentSession(session, config.directory) })
: Effect.fail(new MockNotFound({ message: "Session not found" }))
},
sessionRemove: () => noContent,
sessionShell: () => noContent,
sessionForm: (ctx) => {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return Effect.succeed({
data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID),
})
},
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionPermission: (ctx) => {
const permissions =
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
return Effect.succeed({
data: permissions
.map(currentPermission)
.filter((permission) => permission.sessionID === ctx.params.sessionID),
})
},
sessionPermissionReply: () => noContent,
sessionRename: () => noContent,
sessionInterrupt: () => noContent,
sessionRevertStage: (ctx) => {
const payload = record(ctx.payload) ? ctx.payload : {}
const messageID = payload.messageID
if (typeof messageID !== "string") {
return Effect.fail(new MockBadRequest({ message: "Invalid revert request" }))
}
return Effect.sync(() => config.onRevertStage?.({ sessionID: ctx.params.sessionID, messageID })).pipe(
Effect.as({ data: { messageID } }),
)
},
sessionRevertClear: () => noContent,
sessionRevertCommit: () => noContent,
messageGet: (ctx) =>
Effect.gen(function* () {
config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })
yield* delay
const message =
config.message?.(ctx.params.sessionID, ctx.params.messageID) ??
config
.pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER)
.items.find((item) => item.id === ctx.params.messageID)
if (!message) return yield* new MockNotFound({ message: "Message not found" })
return { data: message }
}),
messageList: (ctx) => {
const token = ctx.query.cursor
const before = token ? state.cursors.get(token) : undefined
if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" }))
return Effect.gen(function* () {
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" })
if (config.beforeMessagesResponse) {
yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before }))
}
yield* delay
const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before)
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined
if (cursor) state.cursors.set(cursor, pageData.cursor!)
return {
data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
}
})
},
}),
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
return json(route, { data: [] })
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
if (sessionPermission && route.request().method() === "GET") {
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
return json(route, {
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
})
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const revertStage = path.match(/^\/api\/session\/([^/]+)\/revert\/stage$/)?.[1]
if (revertStage && route.request().method() === "POST") {
const body = route.request().postDataJSON()
if (!body || typeof body !== "object" || !("messageID" in body) || typeof body.messageID !== "string") {
return json(route, { error: "Invalid revert request" }, undefined, 400)
}
config.onRevertStage?.({ sessionID: revertStage, messageID: body.messageID })
return json(route, { data: { messageID: body.messageID } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (messageMatch) {
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message =
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: message })
}
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
})
}
if (url.port === targetPort && targetPort !== appPort)
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
return route.fallback()
})
}
function location(config: MockServerConfig) {
@@ -613,3 +595,24 @@ function jsonValue(value: unknown): JsonValue | undefined {
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events?: unknown[], retry?: number) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
@@ -6,7 +6,3 @@ export function pluginLabel(plugin: PluginInfo) {
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
export function pluginLabels(plugins: readonly PluginInfo[]) {
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
}
@@ -236,7 +236,6 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.window": "Window",
"desktop.menu.help": "Help",
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.settings": "Settings",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
@@ -284,11 +283,6 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.updater.dialog.restart": "Restart",
"desktop.updater.dialog.later": "Later",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode2' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
"desktop.recovery.action.relaunch": "Relaunch",
"desktop.recovery.action.exportLogs": "Export Logs",
"desktop.recovery.action.keepWaiting": "Keep Waiting",
@@ -6,7 +6,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useMcpToggle } from "@/providers/connect/mcp"
import { pluginLabels } from "@/providers/catalog/plugin"
import { pluginLabel } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { InlineServerSelect } from "@/settings/server-select"
import "@/settings/settings.css"
@@ -45,7 +45,9 @@ export const SettingsExtensions: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -7,7 +7,7 @@ import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { pluginLabels } from "@/providers/catalog/plugin"
import { pluginLabel } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
type SkillItem = {
@@ -102,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return pluginLabels(projectPluginList.latest ?? []).filter((name) => !shared.has(name))
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -2,13 +2,6 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("installs the CLI from the macOS application menu", () => {
const appMenu = DESKTOP_MENU.find((menu) => menu.id === "app")
const item = appMenu?.items?.find((entry) => entry.type === "item" && entry.action === "app.installCli")
expect(item).toEqual({ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" })
})
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
@@ -4,7 +4,6 @@ export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.installCli"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
@@ -85,7 +84,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
action: "app.checkForUpdates",
enabled: "updater",
},
{ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" },
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
+2 -2
View File
@@ -6,7 +6,7 @@ import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { pluginLabels } from "@/providers/catalog/plugin"
import { pluginLabel } from "@/providers/catalog/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -39,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -996,12 +996,7 @@ export type ProviderInfo = {
body?: { [x: string]: any }
}
export type ModelCapabilities = {
tools: boolean
input: Array<string>
output: Array<string>
responsesWebsockets?: boolean
}
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelCost = {
tier?: { type: "context"; size: number }
+15 -10
View File
@@ -109,11 +109,16 @@ export const layer = Layer.effect(
},
)
const requireEntry = Effect.fn("Form.requireEntry")((id: ID) =>
Cache.getSuccess(forms, id).pipe(
Effect.flatMap((entry) => Effect.fromOption(entry, () => new NotFoundError({ id }))),
),
)
const find = Effect.fn("Form.find")(function* (id: ID) {
return yield* Cache.getSuccess(forms, id).pipe(
Effect.flatMap((entry) =>
Option.match(entry, {
onNone: () => Effect.fail(new NotFoundError({ id })),
onSome: Effect.succeed,
}),
),
)
})
const create = Effect.fn("Form.create")((input: CreateInput) =>
Effect.uninterruptible(
@@ -146,7 +151,7 @@ export const layer = Layer.effect(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const form = yield* create(input)
const entry = yield* requireEntry(form.id).pipe(Effect.orDie)
const entry = yield* find(form.id).pipe(Effect.orDie)
return yield* restore(Deferred.await(entry.deferred)).pipe(
Effect.onInterrupt(() => Effect.ignore(cancel(form.id))),
)
@@ -155,7 +160,7 @@ export const layer = Layer.effect(
)
const get = Effect.fn("Form.get")(function* (id: ID) {
return (yield* requireEntry(id)).form
return (yield* find(id)).form
})
const list = Effect.fn("Form.list")(function* (input?: ListInput) {
@@ -167,13 +172,13 @@ export const layer = Layer.effect(
})
const state = Effect.fn("Form.state")(function* (id: ID) {
return (yield* requireEntry(id)).state
return (yield* find(id)).state
})
const reply = Effect.fn("Form.reply")((input: ReplyInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const entry = yield* requireEntry(input.id)
const entry = yield* find(input.id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
const invalid = validateAnswer(entry.form.fields, input.answer)
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
@@ -192,7 +197,7 @@ export const layer = Layer.effect(
const cancel = Effect.fn("Form.cancel")((id: ID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const entry = yield* requireEntry(id)
const entry = yield* find(id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
const next: TerminalState = { status: "cancelled" }
yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID })
+15 -26
View File
@@ -1,7 +1,6 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Form } from "@opencode-ai/schema/form"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { configuredSettings } from "./configured.js"
@@ -45,24 +44,23 @@ export const AzurePlugin = define({
if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure")
continue
const resourceName = resolveResourceName(item.provider.settings)
if (resourceName)
evt.provider.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft)) draft.capabilities.responsesWebsockets = true
if (typeof draft.settings?.baseURL !== "string") return
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
})
}
}
@@ -109,12 +107,3 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_RESOURCE_NAME}", resourceName)
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
if (Provider.packageName(model.package ?? provider.package) !== "@ai-sdk/azure") return false
const settings = Provider.mergeOverlay(provider.settings, model.settings)
if (settings?.useCompletionUrls === true || settings?.useDeploymentBasedUrls === true) return false
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
if (typeof settings?.baseURL !== "string") return true
return /^https:\/\/[^/]+\.openai\.azure\.com(?:\/|$)/i.test(settings.baseURL)
}
+1 -6
View File
@@ -190,14 +190,9 @@ export const OpenAIPlugin = define({
})
yield* load()
yield* ctx.catalog.transform((evt) => {
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
})
}
if (!chatgpt) return
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
-11
View File
@@ -4,7 +4,6 @@ import { Clock, Effect, Option, Schema } from "effect"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2"
@@ -13,7 +12,6 @@ const scope = "openid profile email offline_access grok-cli:access api:access"
const pollingSafetyMargin = 3000
const browserMethodID = Integration.MethodID.make("browser")
const deviceMethodID = Integration.MethodID.make("device")
const providerID = Provider.ID.make("xai")
const Token = Schema.Struct({
access_token: Schema.String,
@@ -95,15 +93,6 @@ export const XAIPlugin = define({
draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.catalog.transform((catalog) => {
const provider = catalog.provider.get(providerID)
if (!provider) return
for (const model of provider.models.values()) {
catalog.model.update(providerID, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
})
}
})
}),
})
+27 -42
View File
@@ -37,17 +37,15 @@ const layer = Layer.effect(
// root so opening a subdirectory still describes paths from the project root.
const root = yield* fs.resolve(location.project.directory)
// Same-step parallel reads settle concurrently, so an in-memory claim guards each
// Session/path pair while a load is in flight. The claim is released once the load
// settles: the synthetic message metadata scanned below is the only lasting ledger,
// so paths whose synthetics drop out of model-visible history (compaction, revert)
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
// Session/path pair before any filesystem work. The durable history check below covers
// paths injected in earlier steps after this Location layer was reopened.
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const claimed = yield* Ref.modify(inFlight, (map) => {
const claimed = yield* Ref.modify(injected, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
if (newlyClaimed.length === 0) return [newlyClaimed, map]
@@ -56,43 +54,30 @@ const layer = Layer.effect(
return [newlyClaimed, next]
})
if (claimed.length === 0) return
yield* Effect.gen(function* () {
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
if (toInject.length === 0) return
const files = yield* Effect.forEach(
toInject,
(path) =>
fs
.readFileStringSafe(path)
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
{ concurrency: "unbounded" },
)
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
if (readable.length === 0) return
// Publish directly rather than through Session.synthetic: a Location-scoped layer
// cannot depend on Session (it routes through LocationServiceMap, forming a type
// cycle with this node). The durable publish commits the synthetic and its metadata
// ledger atomically, so releasing the claim afterwards cannot readmit the paths.
yield* bus.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } },
})
}).pipe(
Effect.ensuring(
Ref.update(inFlight, (map) => {
const existing = map.get(input.sessionID)
if (!existing) return map
const remaining = new Set([...existing].filter((path) => !claimed.includes(path)))
const next = new Map(map)
if (remaining.size === 0) next.delete(input.sessionID)
else next.set(input.sessionID, remaining)
return next
}),
),
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
if (toInject.length === 0) return
const files = yield* Effect.forEach(
toInject,
(path) =>
fs
.readFileStringSafe(path)
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
{ concurrency: "unbounded" },
)
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
if (readable.length === 0) return
// Publish directly rather than through Session.synthetic: a Location-scoped layer
// cannot depend on Session (it routes through LocationServiceMap, forming a type
// cycle with this node). The durable publish is what makes the synthetic visible on
// the next projected history reload. The dedup ledger lives on the synthetic message
// metadata so it survives across Location layer restarts.
yield* bus.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } },
})
})
return Service.of({ load })
+8 -8
View File
@@ -42,18 +42,18 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () {
const assistant = yield* adapter.getAssistant(messageID)
if (!assistant) return
yield* adapter.updateAssistant(produce(assistant, recipe))
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
})
const clearCurrentRetry = Effect.gen(function* () {
const assistant = yield* adapter.getCurrentAssistant()
if (!assistant?.retry) return
yield* adapter.updateAssistant(
produce(assistant, (draft) => {
draft.retry = undefined
}),
)
if (assistant?.retry) {
yield* adapter.updateAssistant(
produce(assistant, (draft) => {
draft.retry = undefined
}),
)
}
})
const project = pipe(
+7 -11
View File
@@ -7,6 +7,7 @@ import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
@@ -28,9 +29,6 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -210,6 +208,10 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service
const transport = yield* SessionModelTransport.Service
const app = yield* App.Metadata
const webSocket = yield* Config.boolean("OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET").pipe(
Config.withDefault(false),
Effect.orDie,
)
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.scope.session
const resolved = input.scope.model
@@ -269,13 +271,6 @@ export const layer = Layer.effect(
const webSocketEligible =
!(yield* hooks.has("session", "http.request", resolved.ref.providerID)) &&
!(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const webSocket =
resolved.capabilities.responsesWebsockets === true
? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe(
Config.withDefault(false),
Effect.orDie,
)
: false
const http = webSocketEligible
? undefined
: SessionModelHttp.middleware(hooks, {
@@ -288,7 +283,8 @@ export const layer = Layer.effect(
...(input.webSocket === "session" &&
webSocket &&
webSocketEligible &&
resolved.capabilities.responsesWebsockets === true
resolved.ref.providerID === Provider.ID.openai &&
request.model.route.id === "openai-responses"
? { webSocket: transport.bind(session.id) }
: {}),
}
+5 -13
View File
@@ -3,7 +3,6 @@ export * as SessionRunnerLLM from "./llm.js"
import {
LLMClient,
AIError,
InvalidProviderOutputReason,
LLMEvent,
Message,
isContextOverflowFailure,
@@ -370,6 +369,9 @@ const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
const userRequests = loaded.messages
.findLast((message) => message.type === "user")
?.skills?.map((skill) => ({ action: "skill", resource: skill.id }))
yield* diagnosePromptCache(session.id, prepared.request)
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
@@ -458,6 +460,7 @@ const layer = Layer.effect(
agent: agent.id,
messageID: assistantMessageID,
call: event,
userRequests,
// Progress is ephemeral, not durable history: nothing to order.
progress: (update) => publisher.progress(event.id, update),
}),
@@ -515,18 +518,7 @@ const layer = Layer.effect(
if (overflowFailure) yield* publisher.publish(overflowFailure)
// A thrown LLM failure not already recorded as the provider error either
// escapes as a scheduled retry or fails the assistant durably.
const unknownFinish =
stream._tag === "Success" && publisher.record().finish?.finish === "unknown"
? new AIError({
module: "session",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended with an unknown finish reason.",
}),
})
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmFailure = streamFailure instanceof AIError ? streamFailure : undefined
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
if (
recoverContinuation &&
+8 -6
View File
@@ -89,14 +89,15 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
if (options.finalize) yield* options.finalize(options.draft(next))
})
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
Effect.sync(() => {
transform(draft)
})
const materialize = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
for (const transform of transforms) yield* apply(transform.run, api)
yield* commit(next)
})
@@ -134,7 +135,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
return yield* Deferred.await(done)
})
return {
const result: Interface<State, DraftApi> = {
get: () => state,
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
@@ -175,4 +176,5 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
}),
reload,
}
return result
}
+3
View File
@@ -37,6 +37,7 @@ export interface Snapshot {
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly userRequests?: Tool.Context["userRequests"]
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
}
@@ -234,6 +235,7 @@ const layer = Layer.effect(
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly userRequests?: Tool.Context["userRequests"]
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
@@ -241,6 +243,7 @@ const layer = Layer.effect(
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
...(input.userRequests?.length ? { userRequests: input.userRequests } : {}),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
+9 -8
View File
@@ -51,14 +51,15 @@ export const Plugin = {
const skill = current.find((skill) => skill.id === input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [skill.id],
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
if (!context.userRequests?.some((request) => request.action === name && request.resource === skill.id))
yield* permission.assert({
action: name,
resources: [skill.id],
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
+3 -8
View File
@@ -31,11 +31,6 @@ import { testEffect } from "../lib/effect"
const it = testEffect(Layer.empty)
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
function inFixture(root: string, target: string) {
const relative = path.relative(root, target)
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
}
function testLayer(
directory: string,
globalDirectory = path.join(directory, "global"),
@@ -806,7 +801,7 @@ describe("Config", () => {
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
const entries = (yield* config.entries()).filter((entry) => !entry.path || inFixture(tmp.path, entry.path))
const entries = yield* config.entries()
expect(entries).toEqual([
new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
@@ -866,7 +861,7 @@ describe("Config", () => {
const watcher = yield* Watcher.Test
yield* config.entries()
expect((yield* watcher.subscriptions()).filter((item) => inFixture(tmp.path, item.path))).toEqual([
expect(yield* watcher.subscriptions()).toEqual([
{
type: "directory",
path: AbsolutePath.make(path.join(tmp.path, "global")),
@@ -1511,7 +1506,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const entries = (yield* config.entries()).filter((entry) => !entry.path || inFixture(tmp.path, entry.path))
const entries = yield* config.entries()
const documents = entries.filter((entry) => entry.type === "document")
expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
@@ -242,53 +242,6 @@ describe("AzurePlugin", () => {
),
)
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = {
responses: Model.ID.make("responses"),
chat: Model.ID.make("chat"),
preview: Model.ID.make("preview"),
deploymentURL: Model.ID.make("deployment-url"),
gateway: Model.ID.make("gateway"),
nonAzure: Model.ID.make("non-azure"),
}
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, models.responses, () => {})
draft.model.update(Provider.ID.azure, models.chat, (model) => {
model.settings = { useCompletionUrls: true }
})
draft.model.update(Provider.ID.azure, models.preview, (model) => {
model.settings = { apiVersion: "2025-04-01-preview" }
})
draft.model.update(Provider.ID.azure, models.deploymentURL, (model) => {
model.settings = { useDeploymentBasedUrls: true }
})
draft.model.update(Provider.ID.azure, models.gateway, (model) => {
model.settings = { baseURL: "https://gateway.example/azure" }
})
draft.model.update(Provider.ID.azure, models.nonAzure, (model) => {
model.package = Provider.aisdk("@ai-sdk/anthropic")
})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(Provider.ID.azure, models.responses)).capabilities.responsesWebsockets,
).toBe(true)
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure])
expect(
required(yield* catalog.model.get(Provider.ID.azure, modelID)).capabilities.responsesWebsockets,
).toBeUndefined()
}),
),
)
it.effect("rejects missing resourceName when baseURL is not configured", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
@@ -197,7 +197,6 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.capabilities.responsesWebsockets).toBe(true)
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.hasHttpHooks).toBe(false)
expect(provider.headers).not.toHaveProperty("originator")
@@ -205,7 +204,7 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("selects Azure WebSocket from capability and the Azure flag only", () =>
it.effect("selects WebSocket with the built-in provider hooks enabled", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
@@ -222,12 +221,8 @@ describe("OpenAIPlugin", () => {
})
const sessionID = Session.ID.make("ses_websocket_hooks")
const agentID = Agent.ID.make("build")
const route = OpenAIResponses.route.with({
id: "deployment-responses",
provider: Provider.ID.azure,
})
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
const model = SessionRunnerModel.resolved(OpenAIResponses.route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
})
@@ -253,16 +248,6 @@ describe("OpenAIPlugin", () => {
}).pipe(
Effect.provide(SessionModelRequest.layer),
Effect.provideService(SessionModelTransport.Service, transport),
)
const prepared = yield* program.pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "true" } }),
),
),
)
const otherProvider = yield* program.pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
@@ -270,9 +255,10 @@ describe("OpenAIPlugin", () => {
),
)
const prepared = yield* program
expect(prepared.options.webSocket).toBe(executor)
expect(prepared.options.http).toBeUndefined()
expect(otherProvider.options.webSocket).toBeUndefined()
}),
)
})
@@ -1,13 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -68,23 +65,4 @@ describe("XAIPlugin", () => {
})
}),
)
it.effect("marks xAI deployments as Responses WebSocket capable", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("xai")
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/xai")
})
draft.model.update(providerID, Model.ID.make("grok-4.6"), () => {})
})
yield* addPlugin()
expect((yield* catalog.model.get(providerID, Model.ID.make("grok-4.6")))?.capabilities.responsesWebsockets).toBe(
true,
)
}),
)
})
@@ -233,37 +233,6 @@ describe("SessionInstructions", () => {
}),
)
it.effect("re-injects nested instructions dropped from history by compaction", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const subPath = path.resolve(dir, "sub", "AGENTS.md")
yield* mkdir(path.resolve(dir, "sub"))
yield* writeAgents(path.resolve(dir, "AGENTS.md"), "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
const session = yield* Session.Service
const registry = yield* Tool.Service
const bus = yield* Bus.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
yield* executeTool(registry, readCall(sessionID, "call-before", "sub/file.txt"))
expect(yield* synthetics(sessionID)).toHaveLength(1)
// A completed compaction truncates model-visible history at its boundary, dropping
// the synthetic that carried sub's instructions.
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" })
expect(yield* synthetics(sessionID)).toHaveLength(0)
// The model no longer has the rules, so the next read under the subtree must
// re-inject them rather than trusting a stale in-memory claim.
yield* executeTool(registry, readCall(sessionID, "call-after", "sub/file.txt"))
expect(yield* synthetics(sessionID)).toHaveLength(1)
}),
)
it.effect("listing the Location root directory injects no instructions", () =>
Effect.gen(function* () {
const location = yield* Location.Service
-58
View File
@@ -4519,31 +4519,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries an unknown finish before output", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry unknown finish")
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "unknown" } }),
LLMEvent.finish({ reason: { normalized: "unknown" } }),
])
yield* TestLLM.push(TestLLM.text("Recovered", "unknown-finish-success"))
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),
)
it.effect("uses a larger provider retry-after delay", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -4619,39 +4594,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("continues an unknown finish after observable text", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Continue unknown finish")
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "unknown-partial" }),
LLMEvent.textDelta({ id: "unknown-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "unknown-partial" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "unknown" } }),
LLMEvent.finish({ reason: { normalized: "unknown" } }),
])
yield* TestLLM.push(TestLLM.text(" continuation", "unknown-continuation"))
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
})
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "error", content: [{ type: "text", text: "Partial" }] },
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
])
}),
)
it.effect("lowers interrupted reasoning before continuing an incomplete stream", () =>
Effect.gen(function* () {
const session = yield* setup
+13
View File
@@ -131,12 +131,25 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
userRequests: [{ action: "skill", resource: "other" }],
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
status: "error",
error: { type: "permission.rejected", message: "Permission denied: skill" },
})
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
userRequests: [{ action: "skill", resource: "effect" }],
call: { type: "tool-call", id: "call-user-skill", name: "skill", input: { id: "effect" } },
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: Skill.toModelOutput(info, [reference]) }],
})
expect(assertions).toHaveLength(3)
deny = false
const flat = Skill.Info.make({
id: Skill.ID.make("public"),
@@ -5,7 +5,6 @@ import { AppRpcs } from "../../shared/ipc-rpc"
import { openExternalURL } from "../files"
import { checkAppExists, resolveAppPath } from "../files/apps"
import { setForceFocus } from "../native/debug"
import { showCliInstaller } from "../native/install-cli"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
import { setNativeTranslations } from "../native/translations"
@@ -13,7 +12,6 @@ import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
import { BackgroundService } from "../service/background-service"
import { DesktopCli } from "../service/desktop-cli"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
@@ -24,7 +22,6 @@ export const appHandlers = AppRpcs.toLayer(
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const background = yield* BackgroundService.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const logging = yield* DesktopLogging.Service
const runFork = Effect.runForkWith(yield* Effect.context())
@@ -59,7 +56,6 @@ export const appHandlers = AppRpcs.toLayer(
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
installCli: () => runFork(showCliInstaller(desktopCli)),
createWindow: lifecycle.createWindow,
openExternal: (url) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
-4
View File
@@ -16,9 +16,7 @@ import { windowHandlers } from "./ipc-handlers/window"
import { wslHandlers } from "./ipc-handlers/wsl"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
@@ -44,7 +42,6 @@ export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true })
export const registerIpcHandlers = Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const menu = {
@@ -53,7 +50,6 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
installCli: () => runFork(showCliInstaller(desktopCli)),
createWindow: lifecycle.createWindow,
openExternal: (url: string) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
@@ -1,28 +0,0 @@
import { dialog } from "electron"
import { Effect } from "effect"
import { DesktopCli } from "../service/desktop-cli"
import { nativeT } from "./translations"
export function showCliInstaller(desktopCli: DesktopCli.Interface) {
return desktopCli.install.pipe(
Effect.tap((path) =>
Effect.promise(() =>
dialog.showMessageBox({
type: "info",
message: nativeT("desktop.cli.installed.message", { path }),
title: nativeT("desktop.cli.installed.title"),
}),
),
),
Effect.catch((error) =>
Effect.promise(() =>
dialog.showMessageBox({
type: "error",
message: nativeT("desktop.cli.failed.message", { error: error.message }),
title: nativeT("desktop.cli.failed.title"),
}),
),
),
Effect.asVoid,
)
}
@@ -4,7 +4,6 @@ import { updateTitlebar } from "../windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
installCli: () => void
createWindow: () => void
relaunch: () => void
}>
@@ -18,9 +17,6 @@ export function runDesktopMenuAction(
case "app.checkForUpdates":
handlers.checkForUpdates?.()
return
case "app.installCli":
handlers.installCli?.()
return
case "app.relaunch":
handlers.relaunch?.()
return
-2
View File
@@ -16,7 +16,6 @@ import { nativeT } from "./translations"
type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
installCli: () => void
createWindow: () => void
openExternal: (url: string) => void
relaunch: () => void
@@ -61,7 +60,6 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
item.click = () =>
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
checkForUpdates: deps.checkForUpdates,
installCli: deps.installCli,
createWindow: deps.createWindow,
relaunch: deps.relaunch,
})
@@ -1,10 +1,9 @@
export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { parseCliVersion } from "./cli-version"
@@ -19,7 +18,6 @@ export interface Resolved {
export interface Interface {
readonly resolve: Effect.Effect<Resolved>
readonly install: Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopCli") {}
@@ -27,19 +25,10 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const path = yield* Path.Path
const resolve = yield* Effect.cached(
make().pipe(Effect.provide(yield* Effect.context<FileSystem.FileSystem | Path.Path>()), Effect.orDie),
)
const install = Effect.gen(function* () {
if (process.platform !== "darwin") return yield* Effect.fail(new Error("CLI installation requires macOS"))
const cli = yield* resolve
if (!cli.binary) return yield* Effect.fail(new Error("Bundled CLI executable is unavailable"))
const home = app.getPath("home")
yield* runInstaller(cli.binary, home)
return path.join(home, ".opencode", "bin", "opencode2")
})
return Service.of({ resolve, install })
return Service.of({ resolve })
}),
)
@@ -145,27 +134,6 @@ const run = Effect.fn("DesktopCli.run")(function* (binary: string, args: string[
return stdout
})
const runInstaller = Effect.fn("DesktopCli.installForUser")(function* (binary: string, home: string) {
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn("/bin/bash", ["-s", "--", "--binary", binary], {
env: { ...process.env, HOME: home },
stdio: ["pipe", "ignore", "pipe"],
})
let stderr = ""
child.stderr.on("data", (chunk) => (stderr += chunk))
child.on("error", reject)
child.on("close", (code) => {
if (code === 0) return resolve()
reject(new Error(stderr.trim() || `CLI installer exited with code ${code}`))
})
child.stdin.end(installer)
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
})
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
@@ -3,7 +3,6 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
const DesktopMenuAction = Schema.Literals([
"app.checkForUpdates",
"app.installCli",
"app.relaunch",
"edit.undo",
"edit.redo",
-1
View File
@@ -64,7 +64,6 @@ export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
input: Schema.Array(Schema.String),
output: Schema.Array(Schema.String),
responsesWebsockets: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Capabilities" })
export interface Cost extends Schema.Schema.Type<typeof Cost> {}
+1
View File
@@ -16,6 +16,7 @@ export interface Context {
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly id: CallID
readonly userRequests?: ReadonlyArray<{ readonly action: string; readonly resource: string }>
readonly progress: (update: Metadata) => Effect.Effect<void>
}
-10
View File
@@ -58,13 +58,3 @@ describe("Model.Info", () => {
expect(model.limit).toEqual({ context: 200_000, output: 32_000 })
})
})
describe("Model.Capabilities", () => {
test("decodes optional Responses WebSocket support", () => {
const decode = Schema.decodeUnknownSync(Model.Capabilities)
const base = { tools: true, input: ["text"], output: ["text"] }
expect(decode(base)).toEqual(base)
expect(decode({ ...base, responsesWebsockets: true })).toEqual({ ...base, responsesWebsockets: true })
})
})