From ec50db334b43d47b3c520ac7031792295939fc53 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:48:01 +0200 Subject: [PATCH 001/112] fix(opencode): pass configured headers to Copilot models (#32815) --- packages/opencode/src/plugin/github-copilot/copilot.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index ff7f867ab7d..9c744db89b2 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -70,6 +70,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { return CopilotModels.get( base(auth.enterpriseUrl), { + ...(provider.options?.headers as Record | undefined), Authorization: `Bearer ${auth.refresh}`, "User-Agent": `opencode/${InstallationVersion}`, "X-GitHub-Api-Version": API_VERSION, From 62c746f2e8b4bf325fbdf67bdb3246f043609850 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 18 Jun 2026 14:26:49 +0200 Subject: [PATCH 002/112] zen: budget --- packages/console/app/src/routes/zen/util/handler.ts | 3 --- .../app/src/routes/zen/util/providerBudgetTracker.ts | 11 +++++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 18aeedd7887..e34c3e750b8 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -157,9 +157,6 @@ export async function handler( logger.metric({ provider: providerInfo.id, "provider.model": providerInfo.model, - ...(providerBudgetUsage?.[providerInfo.id] - ? { "provider.budget_usage": providerBudgetUsage?.[providerInfo.id] } - : {}), }) const startTimestamp = Date.now() diff --git a/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts b/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts index 83cfd70469a..c8b5eb211c0 100644 --- a/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts +++ b/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts @@ -1,5 +1,6 @@ import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js" import { buildRateLimitKey, getRedis } from "./redis" +import { logger } from "./logger" export function createProviderBudgetTracker( providers: { @@ -22,13 +23,15 @@ export function createProviderBudgetTracker( const keys = Object.fromEntries( tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]), ) + let budgetUsage: Record = {} return { check: async () => { - const ids = tracked.filter((provider) => provider.budgetMode === "fill").map((provider) => provider.id) + const ids = tracked.map((provider) => provider.id) if (ids.length === 0) return {} const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id])) - return Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)])) + budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)])) + return budgetUsage }, track: async (provider: string, costInCent: number) => { const config = tracked.find((item) => item.id === provider) @@ -40,6 +43,10 @@ export function createProviderBudgetTracker( pipeline.incrby(keys[provider], cost) pipeline.expire(keys[provider], 120) await pipeline.exec() + logger.metric({ + "provider.budget_usage": budgetUsage[provider] + cost, + "model.budget_usage": cost, + }) }, } } From 2892e97c578183302fe0fbc200ab05c9684c821a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:54:26 +0200 Subject: [PATCH 003/112] fix(tui): gate background shortcut by capability (#32837) --- .../instance/httpapi/groups/experimental.ts | 15 +++++++ .../instance/httpapi/handlers/experimental.ts | 5 +++ .../test/server/httpapi-exercise/index.ts | 6 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 43 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 34 +++++++++++++++ packages/tui/src/context/sync.tsx | 27 ++++++++++-- packages/tui/src/routes/session/index.tsx | 37 +++++++++------- packages/tui/test/fixture/tui-sdk.ts | 1 + 8 files changed, 148 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 378e0b72174..52c714a5ae6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -25,6 +25,10 @@ const ConsoleStateResponse = Schema.Struct({ switchableOrgCount: NonNegativeInt, }).annotate({ identifier: "ConsoleState" }) +const CapabilitiesResponse = Schema.Struct({ + backgroundSubagents: Schema.Boolean, +}).annotate({ identifier: "ExperimentalCapabilities" }) + const ConsoleOrgOption = Schema.Struct({ accountID: Schema.String, accountEmail: Schema.String, @@ -84,6 +88,7 @@ export const SessionListQuery = Schema.Struct({ }) export const ExperimentalPaths = { + capabilities: "/experimental/capabilities", console: "/experimental/console", consoleOrgs: "/experimental/console/orgs", consoleSwitch: "/experimental/console/switch", @@ -100,6 +105,16 @@ export const ExperimentalApi = HttpApi.make("experimental") .add( HttpApiGroup.make("experimental") .add( + HttpApiEndpoint.get("capabilities", ExperimentalPaths.capabilities, { + query: WorkspaceRoutingQuery, + success: described(CapabilitiesResponse, "Experimental capabilities"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.capabilities.get", + summary: "Get experimental capabilities", + description: "Get experimental features enabled on the OpenCode server.", + }), + ), HttpApiEndpoint.get("console", ExperimentalPaths.console, { query: WorkspaceRoutingQuery, success: described(ConsoleStateResponse, "Active Console provider metadata"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 57d6464d90c..caed5440052 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -36,6 +36,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const background = yield* BackgroundJob.Service const flags = yield* RuntimeFlags.Service + const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { + return { backgroundSubagents: flags.experimentalBackgroundSubagents } + }) + const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { const [state, groups] = yield* Effect.all( [ @@ -171,6 +175,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper }) return handlers + .handle("capabilities", capabilities) .handle("console", getConsole) .handle("consoleOrgs", listConsoleOrgs) .handle("consoleSwitch", switchConsole) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 3860d742ddc..e8fdadd8ed2 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -578,6 +578,12 @@ const scenarios: Scenario[] = [ .get("/experimental/session", "experimental.session.list") .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) .json(200, array), + http.protected + .get("/experimental/capabilities", "experimental.capabilities.get") + .json(200, (body) => { + check(typeof body === "object" && body !== null, "capabilities should be an object") + check("backgroundSubagents" in body, "capabilities should report background subagents") + }), http.protected .post("/experimental/session/{sessionID}/background", "experimental.session.background") .mutating() diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0b72e620ae5..7c1c9108d95 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -29,6 +29,8 @@ import type { EventTuiPromptAppend, EventTuiSessionSelect, EventTuiToastShow, + ExperimentalCapabilitiesGetErrors, + ExperimentalCapabilitiesGetResponses, ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, ExperimentalConsoleListOrgsErrors, @@ -627,6 +629,42 @@ export class ControlPlane extends HeyApiClient { } } +export class Capabilities extends HeyApiClient { + /** + * Get experimental capabilities + * + * Get experimental features enabled on the OpenCode server. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalCapabilitiesGetResponses, + ExperimentalCapabilitiesGetErrors, + ThrowOnError + >({ + url: "/experimental/capabilities", + ...options, + ...params, + }) + } +} + export class Console extends HeyApiClient { /** * Get active Console provider metadata @@ -1180,6 +1218,11 @@ export class Experimental extends HeyApiClient { return (this._controlPlane ??= new ControlPlane({ client: this.client })) } + private _capabilities?: Capabilities + get capabilities(): Capabilities { + return (this._capabilities ??= new Capabilities({ client: this.client })) + } + private _console?: Console get console(): Console { return (this._console ??= new Console({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 093c1894a8a..e2add116e0e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2144,6 +2144,10 @@ export type Provider = { } } +export type ExperimentalCapabilities = { + backgroundSubagents: boolean +} + export type ConsoleState = { consoleManagedProviders: Array activeOrgName?: string @@ -5549,6 +5553,36 @@ export type ConfigProvidersResponses = { export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ExperimentalCapabilitiesGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/capabilities" +} + +export type ExperimentalCapabilitiesGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalCapabilitiesGetError = + ExperimentalCapabilitiesGetErrors[keyof ExperimentalCapabilitiesGetErrors] + +export type ExperimentalCapabilitiesGetResponses = { + /** + * Experimental capabilities + */ + 200: ExperimentalCapabilities +} + +export type ExperimentalCapabilitiesGetResponse = + ExperimentalCapabilitiesGetResponses[keyof ExperimentalCapabilitiesGetResponses] + export type ExperimentalConsoleGetData = { body?: never path?: never diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 4882c13920f..d6c6a40ebf0 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -65,6 +65,9 @@ export const { provider_default: Record provider_next: ProviderListResponse console_state: ConsoleState + capabilities: { + experimentalBackgroundSubagents: boolean + } provider_auth: Record agent: Agent[] command: Command[] @@ -107,6 +110,9 @@ export const { connected: [], }, console_state: emptyConsoleState, + capabilities: { + experimentalBackgroundSubagents: false, + }, provider_auth: {}, config: {}, status: "loading", @@ -434,6 +440,10 @@ export const { // blocking - include session.list when continuing a session const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) + const capabilitiesPromise = sdk.client.experimental.capabilities + .get({ workspace }, { throwOnError: true }) + .then((x) => x.data) + .catch(() => undefined) const consoleStatePromise = sdk.client.experimental.console .get({ workspace }, { throwOnError: true }) .then((x) => x.data) @@ -443,6 +453,7 @@ export const { await Promise.all([ providersPromise, providerListPromise, + capabilitiesPromise, agentsPromise, configPromise, projectPromise, @@ -451,6 +462,7 @@ export const { .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) + const capabilitiesResponse = capabilitiesPromise const consoleStateResponse = consoleStatePromise const agentsResponse = agentsPromise.then((x) => x.data ?? []) const configResponse = configPromise.then((x) => x.data!) @@ -459,6 +471,7 @@ export const { return Promise.all([ providersResponse, providerListResponse, + capabilitiesResponse, consoleStateResponse, agentsResponse, configResponse, @@ -466,15 +479,21 @@ export const { ]).then((responses) => { const providers = responses[0] const providerList = responses[1] - const consoleState = responses[2] - const agents = responses[3] - const config = responses[4] - const sessions = responses[5] + const capabilities = responses[2] + const consoleState = responses[3] + const agents = responses[4] + const config = responses[5] + const sessions = responses[6] batch(() => { setStore("provider", reconcile(providers.providers)) setStore("provider_default", reconcile(providers.default)) setStore("provider_next", reconcile(providerList)) + setStore( + "capabilities", + "experimentalBackgroundSubagents", + capabilities?.backgroundSubagents === true, + ) setStore("console_state", reconcile(consoleState)) setStore("agent", reconcile(agents)) setStore("config", reconcile(config)) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1a1eb0fcc15..4d983ee99b7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -206,15 +206,17 @@ export function Session() { }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const foregroundTasks = createMemo(() => - messages().flatMap((message) => - (sync.data.part[message.id] ?? []).filter( - (part): part is ToolPart => - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - part.state.metadata?.background !== true, - ), - ), + sync.data.capabilities.experimentalBackgroundSubagents + ? messages().flatMap((message) => + (sync.data.part[message.id] ?? []).filter( + (part): part is ToolPart => + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + part.state.metadata?.background !== true, + ), + ) + : [], ) const userMessageIDs = createMemo( () => @@ -1510,13 +1512,16 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las {childShortcut()} view subagents - x.type === "tool" && - x.tool === "task" && - x.state.status === "running" && - x.state.metadata?.background !== true, - )} + when={ + sync.data.capabilities.experimentalBackgroundSubagents && + props.parts.some( + (x) => + x.type === "tool" && + x.tool === "task" && + x.state.status === "running" && + x.state.metadata?.background !== true, + ) + } > · {backgroundShortcut()} diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 1d3bdd57579..ed18b7acde5 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -58,6 +58,7 @@ export function createFetch(override?: FetchHandler) { return json({}) if (url.pathname === "/config/providers") return json({ providers: {}, default: {} }) if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false }) if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory }) if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } }) if ( From 355a0bcf5bb5e6c7baa271a4b2439a40f286e55d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 18 Jun 2026 12:56:22 +0000 Subject: [PATCH 004/112] chore: generate --- .../test/server/httpapi-exercise/index.ts | 10 ++- packages/sdk/openapi.json | 64 +++++++++++++++++++ packages/tui/src/context/sync.tsx | 6 +- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index e8fdadd8ed2..b1f7bd8b725 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -578,12 +578,10 @@ const scenarios: Scenario[] = [ .get("/experimental/session", "experimental.session.list") .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) .json(200, array), - http.protected - .get("/experimental/capabilities", "experimental.capabilities.get") - .json(200, (body) => { - check(typeof body === "object" && body !== null, "capabilities should be an object") - check("backgroundSubagents" in body, "capabilities should report background subagents") - }), + http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => { + check(typeof body === "object" && body !== null, "capabilities should be an object") + check("backgroundSubagents" in body, "capabilities should report background subagents") + }), http.protected .post("/experimental/session/{sessionID}/background", "experimental.session.background") .mutating() diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 48350fe13df..b0cf1678c78 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -810,6 +810,60 @@ ] } }, + "/experimental/capabilities": { + "get": { + "tags": ["experimental"], + "operationId": "experimental.capabilities.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Experimental capabilities", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExperimentalCapabilities" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Get experimental features enabled on the OpenCode server.", + "summary": "Get experimental capabilities", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.capabilities.get({\n ...\n})" + } + ] + } + }, "/experimental/console": { "get": { "tags": ["experimental"], @@ -21024,6 +21078,16 @@ "required": ["id", "name", "source", "env", "options", "models"], "additionalProperties": false }, + "ExperimentalCapabilities": { + "type": "object", + "properties": { + "backgroundSubagents": { + "type": "boolean" + } + }, + "required": ["backgroundSubagents"], + "additionalProperties": false + }, "ConsoleState": { "type": "object", "properties": { diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d6c6a40ebf0..03db8784de4 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -489,11 +489,7 @@ export const { setStore("provider", reconcile(providers.providers)) setStore("provider_default", reconcile(providers.default)) setStore("provider_next", reconcile(providerList)) - setStore( - "capabilities", - "experimentalBackgroundSubagents", - capabilities?.backgroundSubagents === true, - ) + setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true) setStore("console_state", reconcile(consoleState)) setStore("agent", reconcile(agents)) setStore("config", reconcile(config)) From 0f6c9b387499f40fe32c75843e6d6fad52912c35 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:36:26 -0500 Subject: [PATCH 005/112] chore(stats): update data seo metadata --- packages/stats/app/src/app.tsx | 7 +++++-- packages/stats/app/src/routes/[lab]/[model].tsx | 6 ++---- packages/stats/app/src/routes/[lab]/index.tsx | 4 ++-- packages/stats/app/src/routes/index.tsx | 5 +++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/stats/app/src/app.tsx b/packages/stats/app/src/app.tsx index b969043abd1..c3e66c0488f 100644 --- a/packages/stats/app/src/app.tsx +++ b/packages/stats/app/src/app.tsx @@ -7,8 +7,11 @@ import "./app.css" function AppMeta() { return ( <> - OpenCode Data - + AI Model Usage Rankings | OpenCode Data + ) } diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index cd7e43dfc9a..8614a03d077 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -120,11 +120,9 @@ export default function StatsModel() { const [themePreference, setThemePreference] = createSignal("system") const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? "Model") const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam())) - const modelTitle = createMemo(() => `${modelName()} Data`) + const modelTitle = createMemo(() => `${modelName()} Usage, Cost & Rank | OpenCode Data`) const modelDescription = createMemo(() => - stats() - ? `${modelName()} usage, rank, token mix, cost, geo breakdown, and peer data across OpenCode Go.` - : `${modelName()} model facts, limits, and OpenCode Go usage availability.`, + `View ${modelName()} OpenCode Go usage data, including token volume, weekly rank, token mix, costs, cache ratio, sessions, geo breakdowns, and peer models.`, ) const modelUrl = createMemo(() => new URL( diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 18f9545d08a..1c4cc7cc08a 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -69,10 +69,10 @@ export default function StatsLab() { const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam())) - const labTitle = createMemo(() => `${labName()} Models`) + const labTitle = createMemo(() => `${labName()} AI Model Usage & Rankings | OpenCode Data`) const labDescription = createMemo( () => - `Explore ${labName()} models used in OpenCode, with recent token usage, context windows, release dates, and model-specific data.`, + `Compare ${labName()} models used in OpenCode Go, including token usage, model rankings, context windows, release dates, costs, and model-specific data.`, ) const labUrl = createMemo(() => new URL(lab()?.id ?? labParam(), statsCanonicalBaseUrl).toString()) const updateThemePreference = (preference: ThemePreference) => { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 6ee3b46e2ae..7f528015a0f 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -49,8 +49,9 @@ const rangeLabels: Record = { "1M": "1 Month", "2M": "2 Months", } -const statsHomeTitle = "OpenCode Data" -const statsHomeDescription = "OpenCode usage data, market share, token cost, and session cost." +const statsHomeTitle = "AI Model Usage Rankings | OpenCode Data" +const statsHomeDescription = + "Explore OpenCode Go usage across AI models, including token volume, rankings, market share, token pricing, session cost, cache ratio, and geo breakdowns." const statsHomeFallbackUrl = "https://opencode.ai/data/" const statsUnfurlPath = "banner.jpg" const statsUnfurlAlt = "OpenCode Data wordmark on a dark patterned background" From 10ec856ff21c0f137d4c17bdee650203493f2c62 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 19 Jun 2026 16:38:37 +0000 Subject: [PATCH 006/112] chore: generate --- packages/stats/app/src/routes/[lab]/[model].tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 8614a03d077..92f99fe7d3e 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -121,8 +121,9 @@ export default function StatsModel() { const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? "Model") const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam())) const modelTitle = createMemo(() => `${modelName()} Usage, Cost & Rank | OpenCode Data`) - const modelDescription = createMemo(() => - `View ${modelName()} OpenCode Go usage data, including token volume, weekly rank, token mix, costs, cache ratio, sessions, geo breakdowns, and peer models.`, + const modelDescription = createMemo( + () => + `View ${modelName()} OpenCode Go usage data, including token volume, weekly rank, token mix, costs, cache ratio, sessions, geo breakdowns, and peer models.`, ) const modelUrl = createMemo(() => new URL( From c6083a474c6a6fce5df631b3e1464e12740238e7 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:39:02 +0200 Subject: [PATCH 007/112] test(app): add manual performance diagnostics (#32937) --- packages/app/AGENTS.md | 5 + packages/app/e2e/performance/AGENTS.md | 13 + packages/app/e2e/performance/README.md | 77 +++ packages/app/e2e/performance/benchmark.ts | 144 +++++ packages/app/e2e/performance/chrome-trace.ts | 95 +++ .../app/e2e/performance/playwright.config.ts | 20 + .../performance/playwright.uncapped.config.ts | 13 + .../timeline/session-tab-flash.spec.ts | 49 ++ .../timeline/session-tab-repaint-probe.ts | 251 ++++++++ .../session-tab-switch-benchmark.spec.ts | 79 +++ .../timeline/session-tab-switch-metrics.ts | 46 ++ .../timeline/session-tab-switch-probe.ts | 152 +++++ .../session-timeline-benchmark.fixture.ts | 488 ++++++++++++++++ .../session-timeline-benchmark.spec.ts | 85 +++ .../timeline/session-timeline-profile.ts | 40 ++ .../timeline/session-timeline-stream-probe.ts | 547 ++++++++++++++++++ .../session-timeline-stress.fixture.ts | 335 +++++++++++ .../timeline/timeline-test-helpers.ts | 67 +++ .../unit/chrome-trace-write.test.ts | 15 + .../unit/session-tab-repaint-probe.test.ts | 42 ++ .../unit/session-tab-switch-metrics.test.ts | 54 ++ .../session-timeline-stream-probe.test.ts | 14 + .../session-timeline-visual-tracking.test.ts | 16 + packages/app/e2e/utils/mock-server.ts | 8 +- packages/app/package.json | 3 +- packages/app/playwright.config.ts | 1 + 26 files changed, 2656 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/performance/AGENTS.md create mode 100644 packages/app/e2e/performance/README.md create mode 100644 packages/app/e2e/performance/benchmark.ts create mode 100644 packages/app/e2e/performance/chrome-trace.ts create mode 100644 packages/app/e2e/performance/playwright.config.ts create mode 100644 packages/app/e2e/performance/playwright.uncapped.config.ts create mode 100644 packages/app/e2e/performance/timeline/session-tab-flash.spec.ts create mode 100644 packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts create mode 100644 packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts create mode 100644 packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts create mode 100644 packages/app/e2e/performance/timeline/session-tab-switch-probe.ts create mode 100644 packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts create mode 100644 packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts create mode 100644 packages/app/e2e/performance/timeline/session-timeline-profile.ts create mode 100644 packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts create mode 100644 packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts create mode 100644 packages/app/e2e/performance/timeline/timeline-test-helpers.ts create mode 100644 packages/app/e2e/performance/unit/chrome-trace-write.test.ts create mode 100644 packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts create mode 100644 packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts create mode 100644 packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts create mode 100644 packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 765e960c817..2e56066e7d1 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -1,3 +1,8 @@ +## Priorities + +- Prioritise, in this order: stability, simplicity, performance. +- Before changing session or timeline code, record a production benchmark baseline and compare it after the change. + ## Debugging - NEVER try to restart the app, or the server process, EVER. diff --git a/packages/app/e2e/performance/AGENTS.md b/packages/app/e2e/performance/AGENTS.md new file mode 100644 index 00000000000..e69c91a98fc --- /dev/null +++ b/packages/app/e2e/performance/AGENTS.md @@ -0,0 +1,13 @@ +- Prioritize stability, then simplicity, then measurement overhead. +- Use Playwright for scenario control, isolation, and completion checks. +- Use Chrome Performance traces for generic browser profiling. +- Use Electron `contentTracing` for packaged multi-process profiling. +- Keep custom probes only for product-specific measurements. +- Do not duplicate measurements across the harness, probes, and traces. +- Run benchmarks serially to avoid cross-test contention. +- Run benchmarks against production builds. +- Keep detailed profiling opt-in when it changes workload behavior. +- Preserve raw diagnostic data or use lossless representations. +- Do not enforce machine-dependent performance thresholds. +- Assert scenario completion and metric collection only. +- Keep normal test discovery free of manual benchmarks. diff --git a/packages/app/e2e/performance/README.md b/packages/app/e2e/performance/README.md new file mode 100644 index 00000000000..afa3108d5cc --- /dev/null +++ b/packages/app/e2e/performance/README.md @@ -0,0 +1,77 @@ +# Manual app performance suite + +The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially. + +Run the suite explicitly from `packages/app`: + +```sh +bun run test:bench +``` + +PowerShell: + +```powershell +$env:PLAYWRIGHT_WORKERS = "1" +bun run test:bench +``` + +The suite contains: + +- cold and hot session-tab timing +- cached session repaint and mutation tracing +- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics + +All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle. + +New benchmarks should look like normal Playwright tests: + +```ts +import { benchmark, expect } from "../benchmark" + +benchmark("measures one interaction", async ({ page, report }) => { + // Only scenario-specific setup and interaction belong here. + report({ durationMs: 42 }) +}) +``` + +The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line. + +```text +BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}} +``` + +Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows. + +This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer). + +These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation. + +CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling. + +The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device. + +Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts. + +Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing. + +## Chrome traces + +Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically: + +```sh +OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \ +bunx playwright test --config e2e/performance/playwright.config.ts \ + timeline/session-tab-switch-benchmark.spec.ts +``` + +The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies: + +Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. + +```sh +bunx devtools-tracing stats +``` + +INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`. + +`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration. diff --git a/packages/app/e2e/performance/benchmark.ts b/packages/app/e2e/performance/benchmark.ts new file mode 100644 index 00000000000..b9f8ea43411 --- /dev/null +++ b/packages/app/e2e/performance/benchmark.ts @@ -0,0 +1,144 @@ +import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test" +import { startChromeTrace } from "./chrome-trace" + +type BenchmarkFixtures = { + report: (metrics: Record, context?: Record) => void + reportState: { payload?: { metrics: Record; context: Record } } + benchmarkResult: void +} + +export type PerformancePageDiagnostics = { + navigations: string[] + stop: () => Promise +} + +const pages = new WeakMap() + +export const benchmark = base.extend({ + reportState: async ({}, use) => use({}), + report: async ({ reportState }, use) => { + await use((metrics, context = {}) => { + if (reportState.payload) throw new Error("Benchmark reported metrics more than once") + reportState.payload = { metrics, context } + }) + }, + benchmarkResult: [ + async ({ reportState }, use, testInfo) => { + await use() + const missing = !reportState.payload + console.log( + `BENCHMARK ${JSON.stringify({ + schemaVersion: 2, + runID: process.env.OPENCODE_PERFORMANCE_RUN_ID, + name: benchmarkName(testInfo), + status: missing ? "failed" : testInfo.status, + expectedStatus: testInfo.expectedStatus, + retry: testInfo.retry, + repeatEachIndex: testInfo.repeatEachIndex, + context: { + project: testInfo.project.name, + platform: process.platform, + ...reportState.payload?.context, + }, + metrics: reportState.payload?.metrics ?? null, + error: missing ? "Benchmark did not report metrics" : undefined, + })}`, + ) + if (missing && testInfo.status === testInfo.expectedStatus) + throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`) + }, + { auto: true }, + ], + page: async ({ page }, use, testInfo) => { + const name = benchmarkName(testInfo) + const diagnostics = await observePerformancePage(page, name) + try { + await use(page) + } finally { + try { + await reportPerformancePage(name, diagnostics, testInfo) + } finally { + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach("performance-navigations", { + body: JSON.stringify(diagnostics.navigations, null, 2), + contentType: "application/json", + }) + } + } + } + }, +}) + +function benchmarkName(testInfo: TestInfo) { + return testInfo.titlePath.slice(1).join(" > ") +} + +export { expect } + +async function observePerformancePage(page: Page, name: string) { + const navigations: string[] = [] + const onNavigation = (frame: ReturnType) => { + if (frame === page.mainFrame()) navigations.push(frame.url()) + } + page.on("framenavigated", onNavigation) + const stopTrace = await startChromeTrace(page, name).catch((error) => { + page.off("framenavigated", onNavigation) + throw error + }) + let stopping: Promise | undefined + const diagnostics: PerformancePageDiagnostics = { + navigations, + stop() { + page.off("framenavigated", onNavigation) + return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined)) + }, + } + pages.set(page, diagnostics) + return diagnostics +} + +export async function withBenchmarkPage( + browser: Browser, + name: string, + run: (page: Page) => Promise, + testInfo?: TestInfo, +) { + const context = await browser.newContext() + try { + const page = await context.newPage() + const diagnostics = await observePerformancePage(page, name) + try { + return await run(page) + } finally { + await reportPerformancePage(name, diagnostics, testInfo) + } + } finally { + await context.close() + } +} + +async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) { + const trace = await diagnostics.stop() + console.log( + `BENCHMARK_PAGE ${JSON.stringify({ + schemaVersion: 2, + runID: process.env.OPENCODE_PERFORMANCE_RUN_ID, + name, + test: testInfo ? benchmarkName(testInfo) : undefined, + retry: testInfo?.retry, + repeatEachIndex: testInfo?.repeatEachIndex, + context: { + platform: process.platform, + trace, + selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1", + }, + navigations: diagnostics.navigations, + })}`, + ) +} + +export function benchmarkDiagnostics(page: Page) { + const diagnostics = pages.get(page) + if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page") + return diagnostics +} diff --git a/packages/app/e2e/performance/chrome-trace.ts b/packages/app/e2e/performance/chrome-trace.ts new file mode 100644 index 00000000000..343526e254d --- /dev/null +++ b/packages/app/e2e/performance/chrome-trace.ts @@ -0,0 +1,95 @@ +import type { CDPSession, Page } from "@playwright/test" +import path from "node:path" +import { mkdir, open, rename } from "node:fs/promises" +import { Buffer } from "node:buffer" +import { createHash, randomUUID } from "node:crypto" + +const categories = [ + "-*", + "devtools.timeline", + "v8.execute", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.frame", + "toplevel", + "blink.console", + "blink.user_timing", + "latencyInfo", + "disabled-by-default-devtools.timeline.stack", + "disabled-by-default-v8.cpu_profiler", +] + +export async function startChromeTrace(page: Page, name: string) { + const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR + if (!directory) return + + const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1" + const file = await prepareChromeTrace(directory, name, selectors) + const session = await page.context().newCDPSession(page) + try { + await session.send("Tracing.start", { + transferMode: "ReturnAsStream", + traceConfig: { + excludedCategories: categories + .filter((category) => category.startsWith("-")) + .map((category) => category.slice(1)), + includedCategories: [ + ...categories.filter((category) => !category.startsWith("-")), + ...(selectors + ? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"] + : []), + ], + }, + }) + } catch (error) { + await Promise.allSettled([session.detach()]) + throw error + } + let stopping: Promise | undefined + + return () => + (stopping ??= (async () => { + try { + const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) => + session.once("Tracing.tracingComplete", resolve), + ) + await session.send("Tracing.end") + const result = await complete + if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`) + const partial = `${file}.partial` + await writeProtocolStream(session, result.stream, partial) + if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`) + await rename(partial, file) + return file + } finally { + await Promise.allSettled([session.detach()]) + } + })()) +} + +export async function prepareChromeTrace( + directory: string, + name: string, + selectors: boolean, + nonce = randomUUID().slice(0, 8), +) { + await mkdir(directory, { recursive: true }) + const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual" + const hash = createHash("sha256").update(name).digest("hex").slice(0, 8) + return path.join( + directory, + `${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`, + ) +} + +async function writeProtocolStream(session: CDPSession, handle: string, file: string) { + const output = await open(file, "wx") + try { + while (true) { + const chunk = await session.send("IO.read", { handle }) + await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data) + if (chunk.eof) break + } + } finally { + await Promise.allSettled([output.close(), session.send("IO.close", { handle })]) + } +} diff --git a/packages/app/e2e/performance/playwright.config.ts b/packages/app/e2e/performance/playwright.config.ts new file mode 100644 index 00000000000..d4793daee58 --- /dev/null +++ b/packages/app/e2e/performance/playwright.config.ts @@ -0,0 +1,20 @@ +import config from "../../playwright.config" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000) +process.env.PLAYWRIGHT_SERVER_PORT = String(port) +process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}` + +export default { + ...config, + testDir: ".", + testIgnore: "unit/**", + outputDir: "../test-results/performance", + fullyParallel: false, + workers: 1, + reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]], + webServer: { + ...config.webServer, + command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`, + reuseExistingServer: false, + }, +} diff --git a/packages/app/e2e/performance/playwright.uncapped.config.ts b/packages/app/e2e/performance/playwright.uncapped.config.ts new file mode 100644 index 00000000000..9097c11f1d0 --- /dev/null +++ b/packages/app/e2e/performance/playwright.uncapped.config.ts @@ -0,0 +1,13 @@ +import config from "./playwright.config" + +export default { + ...config, + outputDir: "../test-results/performance-uncapped", + reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]], + use: { + ...config.use, + launchOptions: { + args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"], + }, + }, +} diff --git a/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts new file mode 100644 index 00000000000..741084751f1 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts @@ -0,0 +1,49 @@ +import { benchmark, expect } from "../benchmark" +import { expectSessionTitle } from "../../utils/waits" +import { fixture } from "./session-timeline-stress.fixture" +import { + collectCachedRepaintTrace, + compressCachedRepaintTrace, + installCachedRepaintProbe, + waitForCachedRepaintWindow, +} from "./session-tab-repaint-probe" +import { waitForStableTimeline } from "./session-tab-switch-probe" +import { + installStressSessionTabs, + installTimelineSettings, + mockStressTimeline, + stressSessionHref, +} from "./timeline-test-helpers" + +benchmark("samples cached session repaint after the click", async ({ page, report }) => { + benchmark.setTimeout(120_000) + await mockStressTimeline(page) + await installStressSessionTabs(page) + await installTimelineSettings(page) + await page.goto(stressSessionHref(fixture.targetID)) + await expectSessionTitle(page, fixture.expected.targetTitle) + await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!) + await page + .locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`) + .first() + .click() + await expectSessionTitle(page, fixture.expected.sourceTitle) + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) + + await installCachedRepaintProbe(page, { + targetHref: stressSessionHref(fixture.targetID), + destination: fixture.messages[fixture.targetID].map((message) => message.info.id), + source: fixture.messages[fixture.sourceID].map((message) => message.info.id), + last: fixture.expected.targetMessageIDs.at(-1)!, + windowMs: 1_000, + }) + + await page + .locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`) + .first() + .click() + await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)]) + const result = await collectCachedRepaintTrace(page) + report(compressCachedRepaintTrace(result)) + expect(result.samples.length).toBeGreaterThan(0) +}) diff --git a/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts b/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts new file mode 100644 index 00000000000..862e080f13d --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts @@ -0,0 +1,251 @@ +import type { Page } from "@playwright/test" + +type CachedRepaintTrace = { + timeOriginEpochMs: number + startedAtPerformanceMs: number + samples: { + observedAtMs: number + root: number | undefined + scrollTop: number + scrollHeight: number + bottomErrorPx: number | undefined + last: boolean + rows: { key: string | undefined; node: number; top: number; bottom: number }[] + mounted: number + center: string | undefined + destination: string[] + source: string[] + }[] + mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[] + shifts: { occurredAtMs: number; value: number }[] + windowMs: number + running: boolean + stop: () => void +} + +export async function installCachedRepaintProbe( + page: Page, + input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number }, +) { + await page.evaluate(({ targetHref, destination, source, last, windowMs }) => { + const destinationIDs = new Set(destination) + const sourceIDs = new Set(source) + const nodeIDs = new WeakMap() + let nextNodeID = 1 + const id = (node: Node) => { + const current = nodeIDs.get(node) + if (current) return current + nodeIDs.set(node, nextNodeID) + return nextNodeID++ + } + const state: CachedRepaintTrace = { + timeOriginEpochMs: performance.timeOrigin, + startedAtPerformanceMs: 0, + samples: [], + mutations: [], + shifts: [], + windowMs, + running: false, + stop: () => {}, + } + const recordShifts = (entries: PerformanceEntry[]) => { + if (!state.running) return + state.shifts.push( + ...entries + .map((entry) => { + if ( + entry.startTime < state.startedAtPerformanceMs || + entry.startTime > state.startedAtPerformanceMs + state.windowMs + ) + return + return { + occurredAtMs: entry.startTime - state.startedAtPerformanceMs, + value: (entry as PerformanceEntry & { value: number }).value, + } + }) + .filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined), + ) + } + const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries())) + shiftObserver.observe({ type: "layout-shift" }) + const recordMutations = (entries: MutationRecord[]) => { + if (!state.running) return + const observedAtMs = performance.now() - state.startedAtPerformanceMs + if (observedAtMs > state.windowMs) return + const changed = entries.flatMap((entry) => [ + ...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })), + ...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })), + ]) + if (changed.length) state.mutations.push({ observedAtMs, changed }) + } + const mutationObserver = new MutationObserver(recordMutations) + mutationObserver.observe(document.documentElement, { childList: true, subtree: true }) + state.stop = () => { + recordShifts(shiftObserver.takeRecords()) + recordMutations(mutationObserver.takeRecords()) + state.running = false + shiftObserver.disconnect() + mutationObserver.disconnect() + } + const sample = () => { + if (!state.running) return + setTimeout(() => { + if (!state.running) return + const observedAtMs = performance.now() - state.startedAtPerformanceMs + if (observedAtMs > state.windowMs) return + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const rows = [...root.querySelectorAll("[data-timeline-key]")] + .map((element) => ({ + key: element.dataset.timelineKey, + node: id(element), + rect: element.getBoundingClientRect(), + })) + .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom) + .map((item) => ({ + key: item.key, + node: item.node, + top: item.rect.top - view.top, + bottom: item.rect.bottom - view.top, + })) + const messages = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() + state.samples.push({ + observedAtMs, + root: id(root), + scrollTop: root.scrollTop, + scrollHeight: root.scrollHeight, + bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined, + last: messages.includes(last), + rows, + mounted: root.querySelectorAll("[data-timeline-key]").length, + center: document + .elementFromPoint(view.left + view.width / 2, view.top + view.height / 2) + ?.textContent?.slice(0, 80), + destination: messages.filter((messageID) => destinationIDs.has(messageID)), + source: messages.filter((messageID) => sourceIDs.has(messageID)), + }) + } else { + state.samples.push({ + observedAtMs, + root: undefined, + scrollTop: 0, + scrollHeight: 0, + bottomErrorPx: undefined, + last: false, + rows: [], + mounted: 0, + center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80), + destination: [], + source: [], + }) + } + requestAnimationFrame(sample) + }, 0) + } + document.addEventListener( + "click", + (event) => { + const link = event.target instanceof Element ? event.target.closest("a") : undefined + if (link?.getAttribute("href") !== targetHref) return + state.startedAtPerformanceMs = performance.now() + state.running = true + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state + }, input) +} + +export function layoutShiftSample(entry: Pick & { value: number }, started: number) { + if (entry.startTime < started) return + return { occurredAtMs: entry.startTime - started, value: entry.value } +} + +export async function waitForCachedRepaintWindow(page: Page, durationMs: number) { + await page.waitForFunction((durationMs) => { + const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash + return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs + }, durationMs) +} + +export async function collectCachedRepaintTrace(page: Page) { + return page.evaluate(() => { + const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash! + state.stop() + return state + }) +} + +export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) { + const roots = trace.samples.map((sample) => sample.root) + const bottomErrors = trace.samples.flatMap((sample) => + sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)], + ) + const category = (sample: CachedRepaintTrace["samples"][number]) => { + if (sample.source.length) return "source" + if (sample.root === undefined || sample.rows.length === 0) return "blank" + if (!sample.destination.length) return "unknown" + if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct" + return "wrongDestination" + } + return { + samples: trace.samples.length, + durationMs: trace.samples.at(-1)?.observedAtMs ?? 0, + firstSampleObservedMs: trace.samples[0]?.observedAtMs, + firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false, + blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length, + sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length, + wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length, + unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length, + rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length, + mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0, + mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)), + maxBottomErrorPx: Math.max(0, ...bottomErrors), + mutationBatches: trace.mutations.length, + addedNodes: trace.mutations.reduce( + (sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length, + 0, + ), + removedNodes: trace.mutations.reduce( + (sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length, + 0, + ), + layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0), + maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)), + } +} + +export function compressCachedRepaintTrace(trace: CachedRepaintTrace) { + const samples: { + observedAtMs: number[] + state: Omit + }[] = [] + for (const sample of trace.samples) { + const { observedAtMs, ...state } = sample + const previous = samples.at(-1) + if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) { + previous.observedAtMs.push(observedAtMs) + continue + } + samples.push({ observedAtMs: [observedAtMs], state }) + } + return { + timeOriginEpochMs: trace.timeOriginEpochMs, + startedAtPerformanceMs: trace.startedAtPerformanceMs, + windowMs: trace.windowMs, + summary: summarizeCachedRepaintTrace(trace), + samples, + mutations: trace.mutations, + shifts: trace.shifts, + } +} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts new file mode 100644 index 00000000000..2e80d703813 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts @@ -0,0 +1,79 @@ +import type { Page } from "@playwright/test" +import { expectSessionTitle } from "../../utils/waits" +import { benchmark, expect, withBenchmarkPage } from "../benchmark" +import { fixture } from "./session-timeline-stress.fixture" +import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers" +import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe" + +type Result = Awaited> + +benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => { + benchmark.setTimeout(180_000) + const results = { cold: [] as Result[], hot: [] as Result[] } + for (const mode of ["cold", "hot"] as const) { + for (let run = 0; run < 5; run++) { + results[mode].push( + await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo), + ) + } + } + report({ results, summary: summarize(results) }) +}) + +async function trial(page: Page, mode: "cold" | "hot") { + await mockStressTimeline(page) + await installStressSessionTabs(page) + if (mode === "hot") { + await page.goto(stressSessionHref(fixture.targetID)) + await expectSessionTitle(page, fixture.expected.targetTitle) + await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!) + await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle) + } else { + await page.goto(stressSessionHref(fixture.sourceID)) + await expectSessionTitle(page, fixture.expected.sourceTitle) + } + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) + + const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id) + const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id) + const lastID = fixture.expected.targetMessageIDs.at(-1)! + const href = stressSessionHref(fixture.targetID) + const result = await measureSessionSwitch(page, { + destinationIDs, + sourceIDs, + lastID, + href, + switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle), + }) + return result +} + +function summarize(results: Record<"cold" | "hot", Result[]>) { + const stats = (values: (number | null)[]) => { + const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b) + return { + min: sorted[0] ?? null, + median: sorted[Math.floor(sorted.length / 2)] ?? null, + max: sorted.at(-1) ?? null, + missing: values.length - sorted.length, + } + } + return Object.fromEntries( + Object.entries(results).map(([mode, values]) => [ + mode, + { + firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)), + firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)), + stableObservedMs: stats(values.map((value) => value.stableObservedMs)), + }, + ]), + ) +} + +async function switchSession(page: Page, sessionID: string, title: string) { + const href = stressSessionHref(sessionID) + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts new file mode 100644 index 00000000000..e315c2ad43b --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts @@ -0,0 +1,46 @@ +export type SessionSwitchSample = { + observedAtMs: number + destination: string[] + source: string[] + hasVisibleRows: boolean + last: boolean + bottomErrorPx?: number +} + +export function classifySessionSwitch(samples: SessionSwitchSample[]) { + const firstDestination = samples.findIndex((sample) => sample.destination.length > 0) + const firstCorrect = samples.findIndex(isCorrectDestination) + const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3))) + return { + firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null, + firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null, + stableObservedMs: samples[stable + 2]?.observedAtMs ?? null, + wrongDestinationSamples: samples + .slice(firstDestination) + .filter((sample) => sample.destination.length > 0 && !sample.last).length, + blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length, + unknownSamples: samples.filter( + (sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0, + ).length, + sourceSamples: samples.filter((sample) => sample.source.length > 0).length, + } +} + +export function isCorrectDestination(sample: SessionSwitchSample) { + return ( + sample.destination.length > 0 && + sample.source.length === 0 && + sample.last && + Math.abs(sample.bottomErrorPx ?? Infinity) <= 1 + ) +} + +export function isStableSessionSwitch(samples: SessionSwitchSample[]) { + return samples.length === 3 && samples.every(isCorrectDestination) +} + +export function isStableDestination(samples: Pick[]) { + return ( + samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) + ) +} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts new file mode 100644 index 00000000000..14f9d2d003e --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts @@ -0,0 +1,152 @@ +import { expect, type Page } from "@playwright/test" +import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics" + +type SessionSwitchProbe = { + samples: SessionSwitchSample[] + stop: () => void +} + +async function installSessionSwitchProbe( + page: Page, + input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string }, +) { + await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => { + const destination = new Set(destinationIDs) + const source = new Set(sourceIDs) + const samples: SessionSwitchSample[] = [] + let started: number | undefined + let running = true + const sample = () => { + if (!running || started === undefined) return + setTimeout(() => { + if (!running || started === undefined) return + const observedAtMs = performance.now() - started + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const visible = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + const hasVisibleRows = [...root.querySelectorAll("[data-timeline-key]")].some((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() + samples.push({ + observedAtMs, + destination: visible.filter((id) => destination.has(id)), + source: visible.filter((id) => source.has(id)), + hasVisibleRows, + last: visible.includes(lastID), + bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined, + }) + } else { + samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false }) + } + requestAnimationFrame(sample) + }, 0) + } + document.addEventListener( + "click", + (event) => { + const link = event.target instanceof Element ? event.target.closest("a") : undefined + if (link?.getAttribute("href") !== href) return + started = performance.now() + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = { + samples, + stop: () => { + running = false + }, + } + }, input) +} + +async function waitForStableSessionSwitch(page: Page) { + await page.waitForFunction(() => { + const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples + if (!samples) return false + return samples.some((_, index) => { + const stable = samples.slice(index, index + 3) + return ( + stable.length === 3 && + stable.every( + (sample) => + sample.destination.length > 0 && + sample.source.length === 0 && + sample.last && + Math.abs(sample.bottomErrorPx ?? Infinity) <= 1, + ) + ) + }) + }) +} + +async function collectSessionSwitchResult(page: Page) { + const samples = await page.evaluate(() => { + const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe! + probe.stop() + return probe.samples + }) + return classifySessionSwitch(samples) +} + +export async function measureSessionSwitch( + page: Page, + input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise }, +) { + const { switch: run, ...probe } = input + await installSessionSwitchProbe(page, probe) + await run() + await waitForStableSessionSwitch(page) + return collectSessionSwitchResult(page) +} + +export async function waitForStableTimeline(page: Page, lastID: string) { + const samples: Pick[] = [] + await expect + .poll( + async () => { + samples.push( + await page.evaluate( + (lastID) => + new Promise>((resolve) => { + requestAnimationFrame(() => + setTimeout(() => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (!root) { + resolve({ last: false }) + return + } + const view = root.getBoundingClientRect() + const last = [...root.querySelectorAll("[data-message-id]")].some((element) => { + if (element.dataset.messageId !== lastID) return false + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + const spacer = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined }) + }, 0), + ) + }), + lastID, + ), + ) + return isStableDestination(samples.slice(-3)) + }, + { timeout: 30_000, intervals: [0] }, + ) + .toBe(true) +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts new file mode 100644 index 00000000000..6353416d50c --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -0,0 +1,488 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import type { Page } from "@playwright/test" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../../utils/waits" +import { expect } from "../benchmark" + +const directory = "C:/OpenCode/TimelineStateRegression" +const projectID = "proj_timeline_state_regression" +const sessionID = "ses_timeline_state_regression" +const userMessageID = "msg_user_regression" +const assistantMessageID = "msg_assistant_regression" +const editPartID = "prt_0001_edit" +export const textPartID = "prt_9999_text" +const title = "Timeline collapse state regression" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type EventPayload = { + directory: string + payload: Record +} + +const userMessage = { + info: { + id: userMessageID, + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model, + }, + parts: [ + { + id: "prt_user_text", + sessionID, + messageID: userMessageID, + type: "text", + text: "Please edit the file.", + }, + ], +} + +const editPart = { + id: editPartID, + sessionID, + messageID: assistantMessageID, + type: "tool", + callID: "call_edit_regression", + tool: "edit", + state: { + status: "completed", + input: { filePath: "src/regression.ts" }, + output: "Edited src/regression.ts", + title: "src/regression.ts", + metadata: { + filediff: { + file: "src/regression.ts", + additions: 1, + deletions: 1, + before: "export const value = 'before'\n", + after: "export const value = 'after'\n", + }, + diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n", + }, + time: { start: 1700000001000, end: 1700000002000 }, + }, +} + +const streamedTextPart = { + id: textPartID, + sessionID, + messageID: assistantMessageID, + type: "text", + text: "Streaming added a later assistant text part.", +} + +const assistantMessage = { + info: { + id: assistantMessageID, + sessionID, + role: "assistant", + time: { created: 1700000001000 }, + parentID: userMessageID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + }, + parts: [editPart], +} + +export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) { + const events: EventPayload[] = [] + let eventBatch = options.eventBatch + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: provider(), + sessions: [session()], + pageMessages: () => ({ + items: [ + ...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(), + userMessage, + assistantMessage, + ], + }), + events: () => events.splice(0, eventBatch), + eventRetry: 16, + }) + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + showSessionProgressBar: true, + }, + }), + ) + }) + await page.setViewportSize({ width: 1366, height: 768 }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first() + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await expectAppVisible(scroller) + return { + scroller, + text, + transport: { + enqueue(payload: EventPayload | EventPayload[]) { + events.push(...(Array.isArray(payload) ? payload : [payload])) + }, + pendingCount() { + return events.length + }, + releaseAll() { + eventBatch = events.length + }, + }, + async scrollToBottom() { + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + }, + async waitForStableGeometry() { + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + await page.waitForFunction((partID) => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector(`[data-timeline-part-id="${partID}"]`), + ) + if (!root) return false + return new Promise((resolve) => { + const height = root.scrollHeight + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1), + ), + ) + }) + }, textPartID) + }, + } +} + +export function buildInitialStreamEvent(deltaCount: number): EventPayload { + return { + directory, + payload: { + type: "message.part.updated", + properties: { + part: { + ...streamedTextPart, + text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``, + }, + }, + }, + } +} + +export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] { + return Array.from({ length: deltaCount }, (_, index) => ({ + directory, + payload: { + type: "message.part.delta", + properties: { + messageID: assistantMessageID, + partID: textPartID, + field: "text", + delta: streamChunk(index + 1, deltaCount + 1), + }, + }, + })) +} + +function performanceTurn(index: number) { + const suffix = String(index).padStart(4, "0") + const userID = `msg_0000_${suffix}_a_user` + const assistantID = `msg_0000_${suffix}_b_assistant` + const before = historicalSource(index, false) + const after = historicalSource(index, true) + const parts = [ + ...(index % 5 === 0 + ? [ + { + id: `prt_0000_${suffix}_reasoning`, + sessionID, + messageID: assistantID, + type: "reasoning", + text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`, + time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 }, + }, + ] + : []), + { + id: `prt_0000_${suffix}_assistant`, + sessionID, + messageID: assistantID, + type: "text", + text: historicalMarkdown(index), + }, + ...(index % 8 === 0 + ? [ + { + id: `prt_0000_${suffix}_edit`, + sessionID, + messageID: assistantID, + type: "tool", + callID: `call_0000_${suffix}_edit`, + tool: "edit", + state: { + status: "completed", + input: { filePath: `src/history-${index}.ts` }, + output: `Edited src/history-${index}.ts`, + title: `src/history-${index}.ts`, + metadata: { + filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after }, + }, + time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 }, + }, + }, + ] + : []), + ...(index % 12 === 0 + ? [ + { + id: `prt_0000_${suffix}_write`, + sessionID, + messageID: assistantID, + type: "tool", + callID: `call_0000_${suffix}_write`, + tool: "write", + state: { + status: "completed", + input: { filePath: `src/generated-${index}.tsx`, content: after }, + output: `Wrote src/generated-${index}.tsx`, + title: `src/generated-${index}.tsx`, + metadata: { + filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after }, + }, + time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 }, + }, + }, + ] + : []), + ...(index % 16 === 0 + ? [ + { + id: `prt_0000_${suffix}_patch`, + sessionID, + messageID: assistantID, + type: "tool", + callID: `call_0000_${suffix}_patch`, + tool: "apply_patch", + state: { + status: "completed", + input: { patchText: realisticPatch(index) }, + output: "Success. Updated src/components/SessionCard.tsx", + title: "src/components/SessionCard.tsx", + metadata: { + files: [ + { + filePath: "src/components/SessionCard.tsx", + relativePath: "src/components/SessionCard.tsx", + type: "update", + additions: 8, + deletions: 3, + patch: realisticPatch(index), + before, + after, + }, + ], + }, + time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 }, + }, + }, + ] + : []), + ] + return [ + { + info: { + id: userID, + sessionID, + role: "user", + time: { created: 1690000000000 + index * 2_000 }, + summary: { diffs: [] }, + agent: "build", + model, + }, + parts: [ + { + id: `prt_0000_${suffix}_user`, + sessionID, + messageID: userID, + type: "text", + text: `Historical prompt ${index}`, + }, + ], + }, + { + info: { + id: assistantID, + sessionID, + role: "assistant", + time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 }, + parentID: userID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + finish: "stop", + }, + parts, + }, + ] +} + +function historicalMarkdown(index: number) { + const code = `import { For, Show, createSignal } from "solid-js" + +type SessionRow = { id: string; title: string; active: boolean } + +export function SessionList(props: { rows: SessionRow[] }) { + const [selected, setSelected] = createSignal() + return ( +
+ {(row) => ( + + )} +
+ ) +}` + return `## Session renderer review ${index} + +The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call. + +| Concern | Current behavior | Verification | +| --- | --- | --- | +| streaming | appends Markdown blocks | painted frames | +| geometry | anchors visible rows | DOM coordinates | +| tools | preserves expanded state | keyed remount probe | + +> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs. + +${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}` +} + +function historicalSource(index: number, updated: boolean) { + const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()" + const limit = updated ? 24 : 20 + return `import { createMemo, For } from "solid-js" + +type Message = { + id: string + role: "user" | "assistant" + text: string + tokens: { input: number; output: number } +} + +export function MessageSummary(props: { messages: Message[]; locale: string }) { + const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit})) + const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0)) + return ( +
+
{total().toLocaleString(props.locale)} output tokens
+ {(message) =>

{message.text.${method}}

}
+
+ ) +} +` +} + +function realisticPatch(index: number) { + return `*** Begin Patch +*** Update File: src/components/SessionCard.tsx +@@ +-const title = props.session.title.toUpperCase() +-const messages = props.messages.slice(-20) ++const title = props.session.title.toLocaleUpperCase(props.locale) ++const messages = props.messages.filter((message) => message.text.trim()).slice(-24) ++const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0) +@@ +-

{title}

++

{title}

++ {outputTokens.toLocaleString(props.locale)} output tokens +*** End Patch` +} + +export function streamChunk(index: number, count: number) { + if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis` + if (index === count - 1) + return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete ` + + const section = Math.floor(index / 18) + 1 + const fragments = [ + ` continues across three`, + ` or four word`, + ` provider deltas and`, + ` closes in this fragment**. \n\n`, + `| Concern | State`, + ` | Verification |\n|`, + ` --- | ---`, + ` | --- |\n|`, + ` markdown | incremental |`, + ` painted frames | \n\n`, + `\`\`\`tsx\nconst row: SessionRow`, + ` = rows[index] ??`, + ` fallback\nconst title =`, + ` row.title.toLocaleUpperCase(locale)\n`, + `const selected = createMemo(()`, + ` => row.id ===`, + ` activeID()) // stream-${index}\n`, + `// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`, + ] + return fragments[(index - 1) % fragments.length]! +} + +function project() { + return { + id: projectID, + worktree: directory, + vcs: "git", + name: "timeline-state-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + } +} + +function session() { + return { + id: sessionID, + slug: "timeline-state-regression", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + } +} + +function provider() { + return { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + } +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts new file mode 100644 index 00000000000..64d79283f06 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts @@ -0,0 +1,85 @@ +import { benchmark, benchmarkDiagnostics, expect } from "../benchmark" +import { + buildInitialStreamEvent, + buildStreamDeltaEvents, + setupTimelineBenchmark, + textPartID, +} from "./session-timeline-benchmark.fixture" +import { startTimelineProfile } from "./session-timeline-profile" +import { + collectTimelineStreamMetrics, + installTimelineStreamProbe, + startTimelineStreamProbe, +} from "./session-timeline-stream-probe" + +benchmark.describe("performance: session timeline streaming", () => { + benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => { + benchmark.setTimeout(480_000) + const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30) + const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160) + const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320) + const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1) + const minimal = process.env.TIMELINE_MINIMAL === "1" + const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1" + const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0" + const fixture = await setupTimelineBenchmark(page, { + historyTurns, + eventBatch, + }) + + fixture.transport.enqueue(buildInitialStreamEvent(deltaCount)) + const contentStart = performance.now() + await expect(fixture.text).toBeVisible() + await expect(fixture.text).toContainText("Implementation plan") + const initialContentObservedMs = performance.now() - contentStart + await fixture.scrollToBottom() + await fixture.waitForStableGeometry() + + const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU }) + await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal }) + const deltas = buildStreamDeltaEvents(deltaCount) + await startTimelineStreamProbe(page) + fixture.transport.enqueue(deltas) + + await page.waitForFunction( + (finalIndex) => + ( + window as Window & { + __timelineStreamBenchmark?: { applied: { index: number }[] } + } + ).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex), + deltaCount, + { timeout: 420_000 }, + ) + await expect(fixture.text).toContainText("benchmark-complete") + await expect(fixture.text).toContainText("Streaming") + await fixture.waitForStableGeometry() + const metrics = await collectTimelineStreamMetrics(page, { + textPartID, + finalIndex: deltaCount, + navigations: benchmarkDiagnostics(page).navigations, + }) + const delivered = deltas.length - fixture.transport.pendingCount() + await profile.stop() + + report( + { + endToEndInitialContentObservedMs: initialContentObservedMs, + ...metrics, + deliveredDeltas: delivered, + pendingDeltas: fixture.transport.pendingCount(), + }, + { + cpuThrottle, + profileCPU, + profileVisual, + minimal, + queuedDeltas: deltas.length, + historyTurns, + eventBatch, + }, + ) + + await profile.reset() + }) +}) diff --git a/packages/app/e2e/performance/timeline/session-timeline-profile.ts b/packages/app/e2e/performance/timeline/session-timeline-profile.ts new file mode 100644 index 00000000000..e1689498c19 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-profile.ts @@ -0,0 +1,40 @@ +import type { CDPSession, Page } from "@playwright/test" + +export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) { + const cdp = await page.context().newCDPSession(page) + if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle }) + if (options.profileCPU) { + await cdp.send("Profiler.enable") + await cdp.send("Profiler.setSamplingInterval", { interval: 100 }) + await cdp.send("Profiler.start") + } + return { + async stop() { + if (!options.profileCPU) return + const result = await cdp.send("Profiler.stop") + const self = new Map() + result.profile.samples?.forEach((id, index) => { + const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000 + self.set(id, (self.get(id) ?? 0) + duration) + }) + console.log( + "timeline cpu profile", + JSON.stringify( + result.profile.nodes + .map((node) => ({ + function: node.callFrame.functionName || "(anonymous)", + url: node.callFrame.url, + line: node.callFrame.lineNumber + 1, + selfMs: self.get(node.id) ?? 0, + })) + .filter((node) => node.selfMs > 1) + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 40), + ), + ) + }, + async reset() { + if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 }) + }, + } +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts new file mode 100644 index 00000000000..a3cd698cde4 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts @@ -0,0 +1,547 @@ +import type { Page } from "@playwright/test" + +const STREAM_MARKER_PATTERN = "stream-(\\d+)" +const STREAM_FRAGMENT_COUNT = 18 + +type TimelineProbeState = { + started: number + ended: number + profileVisual: boolean + minimal: boolean + frames: number[] + frameAt: number[] + applied: { at: number; index: number }[] + geometry: { + scrollTop: number + scrollHeight: number + clientHeight: number + distance: number + virtualHeight: number + headerHeight: number + }[] + blanks: number + longTasks: number[] + layoutShifts: number[] + visibleMounts: number + visibleUnmounts: number + visibleRows: Set + visibleSubtreeMounts: string[] + visibleSubtreeUnmounts: string[] + visibleSubtreeReplacements: number + visibleSubtreeDropouts: string[] + visibleSubtrees: Map + subtreeKeys: WeakMap + maxOverlap: number + maxGap: number + maxPartTopMovement: number + previousPartTop: number + slowFrames: { + duration: number + index: number + phase: "stream" | "boundary" | "complete" | "unknown" + tokenSpans: number + blocks: number + codeBlocks: number + height: number + distance: number + }[] + scroll: { + calls: number + callNoops: number + sameFrameCalls: number + assignments: number + assignmentNoops: number + lastCallFrame: number + frame: number + } + row: HTMLElement + markdown: HTMLElement + running: boolean + previous: number + cleanup: () => void + start: () => void +} + +export async function installTimelineStreamProbe( + page: Page, + options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean }, +) { + await page.evaluate( + ({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => { + const part = document.querySelector(`[data-timeline-part-id="${textPartID}"]`) + const row = part?.closest("[data-timeline-row]") + const markdown = part?.querySelector('[data-component="markdown"]') + const root = part?.closest(".scroll-view__viewport") + if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes") + const viewport = root.getBoundingClientRect() + const state: TimelineProbeState = { + started: 0, + ended: Infinity, + profileVisual, + minimal, + frames: [], + frameAt: [], + applied: [], + geometry: [], + blanks: 0, + longTasks: [], + layoutShifts: [], + visibleMounts: 0, + visibleUnmounts: 0, + visibleRows: new Set( + [...root.querySelectorAll("[data-timeline-key]")].filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > viewport.top && rect.top < viewport.bottom + }), + ), + visibleSubtreeMounts: [], + visibleSubtreeUnmounts: [], + visibleSubtreeReplacements: 0, + visibleSubtreeDropouts: [], + visibleSubtrees: new Map(), + subtreeKeys: new WeakMap(), + maxOverlap: 0, + maxGap: 0, + maxPartTopMovement: 0, + previousPartTop: part.getBoundingClientRect().top, + slowFrames: [], + scroll: { + calls: 0, + callNoops: 0, + sameFrameCalls: 0, + assignments: 0, + assignmentNoops: 0, + lastCallFrame: -1, + frame: 0, + }, + row, + markdown, + running: false, + previous: 0, + cleanup: () => {}, + start: () => {}, + } + ;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state + const scrollTo = Element.prototype.scrollTo + const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")! + if (profileVisual) { + Element.prototype.scrollTo = function (...args) { + state.scroll.calls += 1 + const top = typeof args[0] === "object" ? args[0]?.top : args[1] + if (typeof top === "number") { + const target = Math.min(top, this.scrollHeight - this.clientHeight) + if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1 + } + if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1 + state.scroll.lastCallFrame = state.scroll.frame + return scrollTo.apply(this, args) + } + Object.defineProperty(Element.prototype, "scrollTop", { + configurable: true, + get: scrollTop.get, + set(value) { + state.scroll.assignments += 1 + if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1 + scrollTop.set!.call(this, value) + }, + }) + } + + const recordLongTasks = (entries: PerformanceEntry[]) => { + if (!state.running) return + state.longTasks.push( + ...entries + .filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended) + .map((entry) => entry.duration), + ) + } + const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries())) + longTaskObserver.observe({ type: "longtask" }) + const recordLayoutShifts = (entries: PerformanceEntry[]) => { + if (!state.running) return + state.layoutShifts.push( + ...entries + .map((entry) => { + const shift = entry as LayoutShiftEntry + if (shift.startTime < state.started || shift.hadRecentInput) return + return shift.value + }) + .filter((value): value is number => value !== undefined), + ) + } + const layoutShiftObserver = profileVisual + ? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries())) + : undefined + layoutShiftObserver?.observe({ type: "layout-shift", buffered: true }) + + const visible = (element: Element) => { + const rect = element.getBoundingClientRect() + const viewport = root.getBoundingClientRect() + const style = getComputedStyle(element) + return ( + element.isConnected && + rect.width > 0 && + rect.height > 0 && + rect.bottom > viewport.top && + rect.top < viewport.bottom && + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) > 0 + ) + } + const critical = [ + "[data-timeline-part-id]", + '[data-component="edit-content"]', + '[data-component="apply-patch-file-diff"]', + '[data-component="file"]', + '[data-component="markdown-code"]', + "[data-markdown-block]", + ].join(",") + const describe = (element: Element) => { + const cached = state.subtreeKeys.get(element) + if (!element.isConnected && cached) return cached + const part = element.closest("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown" + const block = element + .closest("[data-markdown-key]") + ?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "") + const component = + element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName + const key = `${part}:${block ?? "root"}:${component}` + state.subtreeKeys.set(element, key) + return key + } + const recordMutations = (records: MutationRecord[]) => { + if (!state.running) return + records.forEach((record) => { + record.addedNodes.forEach((node) => { + if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) { + state.visibleMounts += 1 + state.visibleRows.add(node) + } + if (!(node instanceof Element)) return + const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical)) + added.forEach((element) => { + if (visible(element)) state.visibleSubtreeMounts.push(describe(element)) + }) + }) + record.removedNodes.forEach((node) => { + if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node)) + state.visibleUnmounts += 1 + if (!(node instanceof Element)) return + const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical)) + removed.forEach((element) => { + const key = describe(element) + if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key) + }) + }) + }) + } + const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined + mutationObserver?.observe(root, { childList: true, subtree: true }) + const currentPart = () => root.querySelector(`[data-timeline-part-id="${textPartID}"]`) + const observeProgress = (at: number) => { + if (!state.running) return + const content = currentPart()?.textContent ?? "" + const index = content.includes("benchmark-complete") + ? finalIndex + : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1) + if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index }) + } + const progressObserver = new MutationObserver(() => observeProgress(performance.now())) + progressObserver.observe(root, { characterData: true, childList: true, subtree: true }) + state.cleanup = () => { + recordLongTasks(longTaskObserver.takeRecords()) + recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? []) + recordMutations(mutationObserver?.takeRecords() ?? []) + if (progressObserver.takeRecords().length) observeProgress(performance.now()) + longTaskObserver.disconnect() + layoutShiftObserver?.disconnect() + mutationObserver?.disconnect() + progressObserver.disconnect() + if (!profileVisual) return + Element.prototype.scrollTo = scrollTo + Object.defineProperty(Element.prototype, "scrollTop", scrollTop) + } + + const sample = (now: number) => { + if (!state.running) return + state.frameAt.push(now) + observeProgress(now) + if (minimal) { + state.frames.push(now - state.previous) + state.previous = now + requestAnimationFrame(sample) + return + } + setTimeout(() => { + if (!state.running) return + state.scroll.frame += 1 + const duration = now - state.previous + state.frames.push(duration) + state.previous = now + const virtualRoot = root.querySelector("[data-timeline-virtual-content]") + const header = root.querySelector("[data-session-title]") + state.geometry.push({ + scrollTop: root.scrollTop, + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + distance: root.scrollHeight - root.clientHeight - root.scrollTop, + virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0, + headerHeight: header?.getBoundingClientRect().height ?? 0, + }) + const viewport = root.getBoundingClientRect() + if (profileVisual) { + const visibleRows = [...root.querySelectorAll("[data-timeline-key]")] + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom) + .sort((a, b) => a.rect.top - b.rect.top) + state.visibleRows = new Set(visibleRows.map((item) => item.element)) + const rows = visibleRows.map((item) => item.rect) + rows.slice(1).forEach((rect, index) => { + const previous = rows[index]! + state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top) + state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom) + }) + const partTop = part.getBoundingClientRect().top + state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop)) + state.previousPartTop = partTop + } + const visibleRow = [...root.querySelectorAll("[data-timeline-row]")].some((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > viewport.top && rect.top < viewport.bottom + }) + if (!visibleRow) state.blanks += 1 + if (profileVisual) { + const subtrees = new Map() + const visibleSubtrees = new Map() + root.querySelectorAll(critical).forEach((element) => { + const key = describe(element) + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + const rendered = + element.isConnected && + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) > 0 + subtrees.set(key, { element, rendered }) + if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) { + const previous = state.visibleSubtrees.get(key) + if (previous && previous !== element && key.startsWith(`${textPartID}:`)) + state.visibleSubtreeReplacements += 1 + visibleSubtrees.set(key, element) + } + }) + state.visibleSubtrees.forEach((element, key) => { + const current = subtrees.get(key) + if (key.startsWith(`${textPartID}:`) && !current?.rendered) { + const markdown = part.querySelector('[data-component="markdown"]') + state.visibleSubtreeDropouts.push( + `${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`, + ) + } + if (element.matches('[data-component="file"]')) { + const hadLines = element.hasAttribute("data-profiler-had-lines") + const hasLines = element.shadowRoot?.querySelector("[data-line]") != null + if (hasLines) element.setAttribute("data-profiler-had-lines", "") + if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`) + } + }) + state.visibleSubtrees = visibleSubtrees + } + if (profileVisual && duration > 33.34) { + const livePart = currentPart() + const content = livePart?.textContent ?? "" + const complete = content.includes("benchmark-complete") + const index = complete + ? finalIndex + : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1) + state.slowFrames.push({ + duration, + index, + phase: complete + ? "complete" + : index >= 0 && index % fragmentCount === 0 + ? "boundary" + : index >= 0 + ? "stream" + : "unknown", + tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0, + blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0, + codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0, + height: livePart?.getBoundingClientRect().height ?? 0, + distance: root.scrollHeight - root.clientHeight - root.scrollTop, + }) + } + requestAnimationFrame(sample) + }, 0) + } + state.start = () => { + state.started = performance.now() + state.previous = state.started + state.running = true + requestAnimationFrame(sample) + } + }, + { ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT }, + ) +} + +export function startTimelineStreamProbe(page: Page) { + return page.evaluate(() => { + const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark + if (!state) throw new Error("missing streaming benchmark state") + state.start() + }) +} + +type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean } + +export function layoutShiftValue( + entry: Pick, + start: number, +) { + if (entry.startTime < start || entry.hadRecentInput) return + return entry.value +} + +export function removeVisibleRow(visible: Set, row: T) { + return visible.delete(row) +} + +export function streamProgress(content: string) { + const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1) + return { + index, + phase: content.includes("benchmark-complete") + ? ("complete" as const) + : index >= 0 && index % STREAM_FRAGMENT_COUNT === 0 + ? ("boundary" as const) + : index >= 0 + ? ("stream" as const) + : ("unknown" as const), + } +} + +export async function collectTimelineStreamMetrics( + page: Page, + options: { textPartID: string; finalIndex: number; navigations: string[] }, +) { + return page.evaluate(({ textPartID, finalIndex, navigations }) => { + const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark + if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`) + state.ended = performance.now() + state.cleanup() + state.running = false + const part = document.querySelector(`[data-timeline-part-id="${textPartID}"]`) + const row = part?.closest("[data-timeline-row]") + const markdown = part?.querySelector('[data-component="markdown"]') + const sorted = state.frames.slice().sort((a, b) => a - b) + const duration = state.frames.reduce((sum, value) => sum + value, 0) + const longestSlowStreak = state.frames.reduce( + (result, value) => { + const current = value > 33.34 ? result.current + 1 : 0 + return { current, longest: Math.max(result.longest, current) } + }, + { current: 0, longest: 0 }, + ).longest + const busyStart = state.applied.at(0)?.at + const completion = state.applied.find((value) => value.index === finalIndex) + const busyEnd = completion?.at + const busyFrames = + busyStart === undefined || busyEnd === undefined + ? [] + : state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd) + const busySorted = busyFrames.slice().sort((a, b) => a - b) + const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0) + const completionObservedMs = (completion?.at ?? NaN) - state.started + const visual = state.profileVisual + ? { + layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0), + maxLayoutShiftValue: Math.max(0, ...state.layoutShifts), + visibleMounts: state.visibleMounts, + visibleUnmounts: state.visibleUnmounts, + visibleSubtreeMounts: state.visibleSubtreeMounts, + visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)], + visibleSubtreeReplacements: state.visibleSubtreeReplacements, + visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)], + maxOverlapPx: state.maxOverlap, + maxGapPx: state.maxGap, + maxPartTopMovementPx: state.maxPartTopMovement, + slowestRafGaps: state.slowFrames + .sort((a, b) => b.duration - a.duration) + .slice(0, 20) + .map((frame) => ({ + durationMs: frame.duration, + index: frame.index, + phase: frame.phase, + tokenSpans: frame.tokenSpans, + blocks: frame.blocks, + codeBlocks: frame.codeBlocks, + heightPx: frame.height, + distancePx: frame.distance, + })), + slowRafGapPhases: Object.fromEntries( + ["stream", "boundary", "complete", "unknown"].map((phase) => { + const frames = state.slowFrames.filter((frame) => frame.phase === phase) + return [ + phase, + { + count: frames.length, + totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0), + maxMs: Math.max(0, ...frames.map((frame) => frame.duration)), + }, + ] + }), + ), + scroll: state.scroll, + } + : null + const geometry = state.minimal + ? null + : { + maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)), + finalDistancePx: state.geometry.at(-1)?.distance ?? 0, + final: state.geometry.at(-1), + distanceTransitionsPx: state.geometry + .map((sample) => Math.round(sample.distance)) + .filter((value, index, values) => index === 0 || value !== values[index - 1]), + bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => { + const previous = state.geometry[index]?.distance ?? 0 + return previous <= 1 && value.distance > 1 + }).length, + blankSamples: state.blanks, + } + return { + capabilities: { visual: state.profileVisual, geometry: !state.minimal }, + completionObservedMs, + deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null, + rafGapSamples: state.frames.length, + rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0, + observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null, + observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null, + observedProgressWindowRafGaps: busyFrames.length, + maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)), + observedProgressTransitions: state.applied.length, + rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0, + rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0, + rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0, + maxRafGapMs: sorted.at(-1) ?? 0, + rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length, + rafGapsOver50Ms: state.frames.filter((value) => value > 50).length, + missedFrameBudgetEquivalents: state.frames.reduce( + (sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1), + 0, + ), + longestRafGapOver33MsStreak: longestSlowStreak, + longTaskCount: state.longTasks.length, + longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0), + visual, + geometry, + rowReplaced: row !== state.row, + markdownReplaced: markdown !== state.markdown, + domTextCharacters: part?.textContent?.length ?? 0, + } + }, options) +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts new file mode 100644 index 00000000000..e5c353e4cd0 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -0,0 +1,335 @@ +const words = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet", + "kilo", + "lima", + "metro", + "nova", + "orbit", + "pixel", + "quartz", + "river", + "signal", + "vector", +] + +const sourceID = "ses_smoke_source" +const targetID = "ses_smoke_target" +const directory = "C:/OpenCode/SmokeProject" +const projectID = "proj_smoke_timeline" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type MessageInfo = Record & { id: string; role: "user" | "assistant" } +type MessagePart = Record & { id: string; type: string; text?: string; tool?: string } +type Message = { info: MessageInfo; parts: MessagePart[] } + +function lorem(seed: number, length: number) { + let out = "" + let i = seed + while (out.length < length) { + const word = words[i % words.length] + out += (out ? " " : "") + word + if (i % 17 === 0) out += ".\n\n" + i += 7 + } + return out.slice(0, length) +} + +function id(prefix: string, value: number) { + return `${prefix}_smoke_${String(value).padStart(4, "0")}` +} + +function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message { + const messageID = id("msg_user", index) + return { + info: { + id: messageID, + sessionID, + role: "user", + time: { created: 1700000000000 + index * 10_000 }, + summary: { diffs }, + agent: "build", + model, + }, + parts: [ + { + id: id("prt_user_text", index), + sessionID, + messageID, + type: "text", + text: lorem(index, textLength), + }, + ], + } +} + +function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message { + const messageID = id("msg_assistant", index) + return { + info: { + id: messageID, + sessionID, + role: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 }, + parentID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + finish: "stop", + }, + parts: parts.map((part) => ({ + ...part, + sessionID, + messageID, + })), + } +} + +function textPart(index: number, partIndex: number, length: number): MessagePart { + const prose = lorem(index * 13 + partIndex, length) + const text = + index % 12 === 0 + ? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\`` + : index % 5 === 0 + ? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\`` + : index % 7 === 0 + ? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.` + : prose + return { id: id(`prt_text_${partIndex}`, index), type: "text", text } +} + +function reasoningPart(index: number, partIndex: number, length: number): MessagePart { + return { + id: id(`prt_reasoning_${partIndex}`, index), + type: "reasoning", + text: lorem(index * 19 + partIndex, length), + time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 }, + } +} + +function toolPart( + index: number, + partIndex: number, + tool: string, + input: Record, + outputLength = 160, +): MessagePart { + const metadata = + tool === "apply_patch" + ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } + : tool === "edit" || tool === "write" + ? { + filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index), + diff: patch(index, outputLength), + preview: patch(index + 1, 420), + } + : tool === "question" + ? { answers: [["Proceed"], ["Keep sample output"]] } + : {} + return { + id: id(`prt_tool_${tool}_${partIndex}`, index), + type: "tool", + callID: id("call", index * 10 + partIndex), + tool, + state: { + status: "completed", + input, + output: lorem(index * 23 + partIndex, outputLength), + title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", + metadata, + time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, + }, + } +} + +function patchFile(seed: number, type: "add" | "update" | "delete") { + return { + filePath: `src/generated/patch-${seed}.ts`, + relativePath: `src/generated/patch-${seed}.ts`, + type, + additions: (seed % 7) + 1, + deletions: type === "add" ? 0 : seed % 4, + patch: patch(seed, 520), + before: type === "add" ? undefined : code(seed, 18), + after: type === "delete" ? undefined : code(seed + 1, 24), + } +} + +function fileDiff(file: string, seed: number) { + const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38 + const before = code(seed, lines, seed % 10 === 0 ? 280 : 32) + const after = + lines === 2 + ? before.replace("value1", "updatedValue1") + : lines === 300 + ? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32) + : before.replace("value4", "updatedValue4").replace("value20", "updatedValue20") + return { + file, + additions: lines === 300 ? 300 : lines === 2 ? 1 : 2, + deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2, + before, + after, + } +} + +function patch(seed: number, length: number) { + return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}` +} + +function code(seed: number, lines: number, width = 32) { + return Array.from( + { length: lines }, + (_, index) => `export const value${index} = "${lorem(seed + index, width)}"`, + ).join("\n") +} + +function turn(index: number): Message[] { + const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : [] + const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff) + const parts = [ + ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []), + ...(index % 3 === 0 + ? [ + toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220), + toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140), + toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180), + toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120), + ] + : []), + textPart(index, 2, 160 + (index % 6) * 90), + ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []), + ...(index % 6 === 0 + ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] + : []), + ...(index % 8 === 0 + ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + : []), + ...(index % 7 === 0 + ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] + : []), + ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), + ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), + ...(index % 13 === 0 + ? [ + toolPart( + index, + 11, + "question", + { questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] }, + 120, + ), + ] + : []), + ...(index % 17 === 0 + ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)] + : []), + ] + return [user, assistantMessage(targetID, index, user.info.id, parts)] +} + +const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() +const sourceMessages = Array.from({ length: 12 }, (_, index) => [ + userMessage(sourceID, index + 1000, 120), + assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), +]).flat() + +function renderable(part: MessagePart) { + if (part.type === "tool" && part.tool === "todowrite") return false + if (part.type === "text") return !!part.text.trim() + if (part.type === "reasoning") return !!part.text.trim() + return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" +} + +function orderedParts(message: Message) { + return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) +} + +export const fixture = { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "smoke-project", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [ + { + id: sourceID, + slug: "source", + projectID, + directory, + title: "Uncommitted changes inquiry", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + { + id: targetID, + slug: "target", + projectID, + directory, + title: "Example Game: sample jump movement & sample physics analysis", + version: "dev", + time: { created: 1700000001000, updated: 1700000001000 }, + }, + ], + sourceID, + targetID, + messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, + expected: { + sourceTitle: "Uncommitted changes inquiry", + targetTitle: "Example Game: sample jump movement & sample physics analysis", + sourceMessageIDs: sourceMessages + .filter((message) => message.info.role === "user") + .map((message) => message.info.id), + targetMessageIDs: targetMessages + .filter((message) => message.info.role === "user") + .map((message) => message.info.id), + targetPartIDs: targetMessages.flatMap((message) => + orderedParts(message) + .filter(renderable) + .map((part) => part.id), + ), + }, +} + +export function pageMessages(sessionID: string, limit: number, before?: string) { + const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] + const end = before + ? Math.max( + 0, + messages.findIndex((message) => message.info.id === before), + ) + : messages.length + const start = Math.max(0, end - limit) + return { + items: messages.slice(start, end), + cursor: start > 0 ? messages[start]!.info.id : undefined, + } +} diff --git a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts new file mode 100644 index 00000000000..dc7e1730718 --- /dev/null +++ b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts @@ -0,0 +1,67 @@ +import type { Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { fixture } from "./session-timeline-stress.fixture" + +export async function installTimelineSettings(page: Page) { + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + showSessionProgressBar: true, + }, + }), + ) + }) +} + +export function mockStressTimeline(page: Page) { + return mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) +} + +export async function installStressSessionTabs(page: Page) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript( + ({ directory, sourceID, targetID, dirBase64, server }) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server, + dirBase64, + sessionId, + })), + ), + ) + }, + { + directory: fixture.directory, + sourceID: fixture.sourceID, + targetID: fixture.targetID, + dirBase64: base64Encode(fixture.directory), + server, + }, + ) +} + +export function stressSessionHref(sessionID: string) { + return `/${base64Encode(fixture.directory)}/session/${sessionID}` +} diff --git a/packages/app/e2e/performance/unit/chrome-trace-write.test.ts b/packages/app/e2e/performance/unit/chrome-trace-write.test.ts new file mode 100644 index 00000000000..456020ff30d --- /dev/null +++ b/packages/app/e2e/performance/unit/chrome-trace-write.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import os from "node:os" +import { prepareChromeTrace } from "../chrome-trace" + +test("creates the configured trace directory", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-")) + try { + const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test") + expect(file).toEndWith("-session-tab-458ed9e3-test.json") + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts b/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts new file mode 100644 index 00000000000..5d20c03a00f --- /dev/null +++ b/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe" + +test("compresses repeated repaint states without losing frame samples", () => { + const state = { + root: 1, + scrollTop: 10, + scrollHeight: 20, + bottomErrorPx: 0, + last: true, + rows: [{ key: "row", node: 2, top: 0, bottom: 10 }], + mounted: 1, + center: "content", + } + const trace = { + timeOriginEpochMs: 1_000, + startedAtPerformanceMs: 100, + samples: [ + { observedAtMs: 16, ...state, destination: ["target"], source: [] }, + { observedAtMs: 32, ...state, destination: ["target"], source: [] }, + { observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] }, + ], + mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }], + shifts: [{ occurredAtMs: 24, value: 0.1 }], + windowMs: 1_000, + running: false, + stop() {}, + } + const compressed = compressCachedRepaintTrace(trace) + const samples = compressed.samples.flatMap((group) => + group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })), + ) + + expect(samples).toEqual(trace.samples) + expect(compressed.mutations).toEqual(trace.mutations) + expect(compressed.shifts).toEqual(trace.shifts) +}) + +test("records layout shifts at occurrence time within the probe window", () => { + expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined() + expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 }) +}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts new file mode 100644 index 00000000000..dd771b7d57c --- /dev/null +++ b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test" +import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics" + +test("counts source and blank samples before the destination is observed", () => { + const result = classifySessionSwitch([ + { observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false }, + { observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false }, + { observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + { observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + { observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + ]) + + expect(result.blankSamples).toBe(1) + expect(result.sourceSamples).toBe(1) + expect(result.unknownSamples).toBe(0) + expect(result.firstDestinationObservedMs).toBe(48) + expect(result.stableObservedMs).toBe(80) +}) + +test("does not classify mixed source and destination content as correct", () => { + const result = classifySessionSwitch([ + { + observedAtMs: 16, + destination: ["destination"], + source: ["source"], + hasVisibleRows: true, + last: true, + bottomErrorPx: 0, + }, + { observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + { observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + { observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + ]) + + expect(result.firstCorrectObservedMs).toBe(32) + expect(result.stableObservedMs).toBe(64) +}) + +test("reports missing correctness without throwing", () => { + const result = classifySessionSwitch([ + { + observedAtMs: 16, + destination: ["destination"], + source: ["source"], + hasVisibleRows: true, + last: true, + bottomErrorPx: 0, + }, + ]) + + expect(result.firstDestinationObservedMs).toBe(16) + expect(result.firstCorrectObservedMs).toBeNull() + expect(result.stableObservedMs).toBeNull() +}) diff --git a/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts b/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts new file mode 100644 index 00000000000..f8fca1adb73 --- /dev/null +++ b/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { streamChunk } from "../timeline/session-timeline-benchmark.fixture" +import { streamProgress } from "../timeline/session-timeline-stream-probe" + +test("classifies emitted stream markers using the fixture cycle", () => { + expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" }) + expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" }) + expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" }) + expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" }) +}) + +test("emits progress markers at fixture boundaries", () => { + expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" }) +}) diff --git a/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts b/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts new file mode 100644 index 00000000000..c0215c5cb6c --- /dev/null +++ b/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test" +import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe" + +test("excludes layout shifts before the probe window and recent input", () => { + expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined() + expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined() + expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3) +}) + +test("classifies removed rows from their last painted visibility", () => { + const row = {} + const visible = new Set([row]) + + expect(removeVisibleRow(visible, row)).toBe(true) + expect(removeVisibleRow(visible, row)).toBe(false) +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index c4ef9f6cc84..cf0c9524314 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -44,7 +44,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()) const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" - if (url.port !== targetPort) return route.fallback() + const appPort = new URL( + process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, + ).port + if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) @@ -72,7 +75,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) } - return json(route, {}) + if (url.port === targetPort && targetPort !== appPort) return json(route, {}) + return route.fallback() }) } diff --git a/packages/app/package.json b/packages/app/package.json index 0b46ec02873..b572e7308b7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -24,7 +24,8 @@ "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", - "test:e2e:report": "playwright show-report e2e/playwright-report" + "test:e2e:report": "playwright show-report e2e/playwright-report", + "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts" }, "license": "MIT", "devDependencies": { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index d9648a88ba6..f68652363d3 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -9,6 +9,7 @@ const reuse = !process.env.CI const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined export default defineConfig({ testDir: "./e2e", + testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**", outputDir: "./e2e/test-results", timeout: 60_000, expect: { From 3f1fffeb6d22fe2f116ddf03c9eed205d6ae70f8 Mon Sep 17 00:00:00 2001 From: Grant Martin <69131375+Grantmartin2002@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:22:27 -0500 Subject: [PATCH 008/112] fix(core): fix command docs in customize-opencode skill (#32718) --- packages/core/src/plugin/skill.ts | 2 +- .../src/plugin/skill/customize-opencode.md | 34 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 7c89ac8e337..620fdc8b9ab 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({ skill: new SkillV2.Info({ name: "customize-opencode", description: - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", location: AbsolutePath.make("/builtin/customize-opencode.md"), content: CustomizeOpencodeContent, }), diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 1c1cbdf3c29..6932dbfd54c 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -43,6 +43,8 @@ already-loaded config until then. | Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | | Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | | Global agents | `~/.config/opencode/agent(s)/.md` | +| Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | +| Global commands | `~/.config/opencode/command(s)/.md` | | Project skills | `.opencode/skill(s)//SKILL.md` | | Global skills | `~/.config/opencode/skill(s)//SKILL.md` | | External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | @@ -96,7 +98,7 @@ Every field is optional. }, "command": { - "deploy": { "description": "...", "prompt": "..." } + "deploy": { "description": "...", "template": "..." } }, "provider": { @@ -151,6 +153,7 @@ Shape notes worth being explicit about: - `skills` is an object with `paths` and/or `urls`, not an array. - `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand. - `agent` is an object keyed by agent name, not an array. +- `command` is an object keyed by command name, not an array. - `plugin` is an array of strings or `[name, options]` tuples, not an object. - `mcp[name].command` is an array of strings, never a single string. `type` is required. - `permission` is either a string action or an object keyed by tool name. @@ -277,6 +280,31 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent `compaction`, `title`, `summary`. To override a built-in's fields, define the same key in `agent: { : { ... } }`. +## Commands + +opencode's command loader scans for `**/*.md` inside command directories. The +file is named after the command, and lives directly inside the `command` folder: + +``` +.opencode/command/deploy.md +``` + +Frontmatter: + +```markdown +--- +description: One sentence describing what the command does. +agent: build +model: anthropic/claude-sonnet-4-6 +--- + +(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input) +``` + +- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter. +- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments. +- Optional: `description`, `agent`, `model`, `variant`, `subtask`. + ## Plugins `plugin:` is an array. Each entry is one of: @@ -415,8 +443,8 @@ When a user's config is broken and opencode won't start, these env vars help: exact shape, or the field is not covered in this skill, fetch `https://opencode.ai/config.json` and read the schema rather than guessing. - Preserve `$schema` and any existing fields the user did not ask to change. -- For agent, skill, and plugin definitions, prefer creating new files in the - correct location over inlining everything in `opencode.json`. +- For agent, command, skill, and plugin definitions, prefer creating new files + in the correct location over inlining everything in `opencode.json`. - If the user's existing config is malformed, point them at the env-var escape hatches above so they can edit from inside opencode without breaking their session. From f092bafe88673db9cf3112daa2de7c768ef34aef Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:10:09 -0400 Subject: [PATCH 009/112] tweak: remove steering wrapper that can bust cache (#33039) --- packages/opencode/src/session/prompt.ts | 18 ------------------ packages/opencode/test/session/prompt.test.ts | 4 +++- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3f85c813f2..b616df6e598 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1304,24 +1304,6 @@ export const layer = Layer.effect( if (step === 1) yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) - if (step > 1 && lastFinished) { - for (const m of msgs) { - if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue - for (const p of m.parts) { - if (p.type !== "text" || p.ignored || p.synthetic) continue - if (!p.text.trim()) continue - p.text = [ - "", - "The user sent the following message:", - p.text, - "", - "Please address this message and continue with your tasks.", - "", - ].join("\n") - } - } - } - yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const [skills, env, instructions, modelMsgs] = yield* Effect.all([ diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 08828018a4a..5cd97f78e88 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1346,7 +1346,9 @@ it.instance( const inputs = yield* llm.inputs expect(inputs).toHaveLength(2) - expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second") + const messages = inputs.at(-1)?.messages + if (!Array.isArray(messages)) throw new Error("expected LLM messages") + expect(messages.at(-1)).toEqual({ role: "user", content: "second" }) }), 3_000, ) From e6cdc543f323bceb8a3e140d35d902c26e69169e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:41:22 -0400 Subject: [PATCH 010/112] fix(tui): render console org load errors inline (#33040) --- .../tui/src/component/dialog-console-org.tsx | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/component/dialog-console-org.tsx b/packages/tui/src/component/dialog-console-org.tsx index ec3f8bea54d..1305a965cbf 100644 --- a/packages/tui/src/component/dialog-console-org.tsx +++ b/packages/tui/src/component/dialog-console-org.tsx @@ -1,9 +1,11 @@ -import { createResource, createMemo } from "solid-js" +import { createResource, createMemo, createSignal } from "solid-js" +import { TextAttributes } from "@opentui/core" import { DialogSelect } from "../ui/dialog-select" import { useSDK } from "../context/sdk" import { useDialog } from "../ui/dialog" import { useToast } from "../ui/toast" import { useTheme } from "../context/theme" +import { errorMessage } from "../util/error" import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2" type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number] @@ -25,14 +27,26 @@ export function DialogConsoleOrg() { const toast = useToast() const { theme } = useTheme() - const [orgs] = createResource(async () => { - const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true }) - return result.data?.orgs ?? [] - }) + const [loadError, setLoadError] = createSignal() + + const [orgs] = createResource(() => + sdk.client.experimental.console + .listOrgs({}, { throwOnError: true }) + .then((result) => result.data?.orgs ?? []) + // Catch so the rejected resource never reaches the memos below: reading + // orgs() in an errored state re-throws and tears down the dialog. + .catch((error) => { + setLoadError(error) + return undefined + }), + ) + + const showError = createMemo(() => Boolean(loadError())) const current = createMemo(() => orgs()?.find((item) => item.active)) const options = createMemo(() => { + if (showError()) return [] const listed = orgs() if (listed === undefined) { return [ @@ -99,5 +113,23 @@ export function DialogConsoleOrg() { })) }) - return title="Switch org" options={options()} current={current()} /> + return ( + + title="Switch org" + options={options()} + current={current()} + renderFilter={!showError()} + locked={showError()} + emptyView={ + showError() ? ( + + + Could not load orgs + + {errorMessage(loadError())} + + ) : undefined + } + /> + ) } From 95237a90a707a527e5fdeb68e8b1222d2cf76b9b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 20 Jun 2026 06:14:06 -0500 Subject: [PATCH 011/112] fix(stats): align model peers ranking --- packages/stats/core/src/domain/home.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index 3023ae32365..d420e52c65e 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -248,15 +248,15 @@ function buildStatsModelData( ) .filter((item) => item.totalTokens > 0) .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) - const peers = aggregateByModelName(rowsForProduct(normalized, SITE_PRODUCT, window.start, window.end)) + const windowPeers = aggregateByModelName(rowsForProduct(normalized, SITE_PRODUCT, window.start, window.end)) .filter((item) => item.totalTokens > 0) .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) const rankIndex = rankPeers.findIndex((item) => item.model === model) const rank = rankIndex >= 0 ? rankIndex + 1 : null const previousRankIndex = previousRankPeers.findIndex((item) => item.model === model) - const peerRankIndex = peers.findIndex((item) => item.model === model) - const peerRank = peerRankIndex >= 0 ? peerRankIndex + 1 : 1 - const totalTokens = peers.reduce((sum, item) => sum + item.totalTokens, 0) + const peerRank = rankIndex >= 0 ? rankIndex + 1 : 1 + const totalTokens = windowPeers.reduce((sum, item) => sum + item.totalTokens, 0) + const peerTokens = rankPeers.reduce((sum, item) => sum + item.totalTokens, 0) return { updatedAt: Number.isFinite(latestUpdate) ? new Date(latestUpdate).toISOString() : null, @@ -266,7 +266,7 @@ function buildStatsModelData( author: formatProvider(current.provider), rank, previousRank: previousRankIndex >= 0 ? previousRankIndex + 1 : null, - totalModels: peers.length, + totalModels: windowPeers.length, tokenShare: totalTokens > 0 ? round((current.totalTokens / totalTokens) * 100, 2) : 0, tokenChange: percentChange(current.totalTokens, previous.totalTokens), totals: { @@ -285,7 +285,7 @@ function buildStatsModelData( usage: buildModelUsage(currentRows, window, "2M"), tokenMix: buildModelTokenMix(current), country: createRangeRecord((range) => buildCountryStats(geo, getWindow(range, earliest, latest))), - peers: buildModelPeers(peers, peerRank, totalTokens), + peers: buildModelPeers(rankPeers, peerRank, peerTokens), } } From 009f3799cd6d28cad5a3e1b3902a80f60f93122e Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 20 Jun 2026 15:35:20 +0200 Subject: [PATCH 012/112] refactor(tui): simplify inline tool spacing (#33097) --- packages/tui/src/routes/session/index.tsx | 47 +++--------- .../inline-tool-wrap-snapshot.test.tsx.snap | 15 ++-- .../tui/inline-tool-wrap-snapshot.test.tsx | 72 +++++++++++-------- 3 files changed, 62 insertions(+), 72 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4d983ee99b7..f36ddd9daba 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -91,6 +91,8 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) +export const alwaysSeparate = new WeakSet() + type RetryAction = Extract["action"] function goUpsellKeys(action: RetryAction) { @@ -160,7 +162,6 @@ const context = createContext<{ showTimestamps: () => boolean showDetails: () => boolean showGenericToolOutput: () => boolean - userMessageIDs: () => ReadonlySet diffWrapMode: () => "word" | "none" providers: () => ReadonlyMap sync: ReturnType @@ -218,14 +219,6 @@ export function Session() { ) : [], ) - const userMessageIDs = createMemo( - () => - new Set( - messages() - .filter((message) => message.role === "user") - .map((message) => message.id), - ), - ) const permissions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) @@ -1158,7 +1151,6 @@ export function Session() { showTimestamps, showDetails, showGenericToolOutput, - userMessageIDs, diffWrapMode, providers, sync, @@ -1395,6 +1387,7 @@ function UserMessage(props: { alwaysSeparate.add(el)} border={["left"]} borderColor={color()} customBorderChars={SplitBorder.customBorderChars} @@ -1532,7 +1525,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} @@ -1547,7 +1540,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las - + alwaysSeparate.add(el)} paddingLeft={3}> alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexDirection="column" @@ -1695,7 +1688,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess const { theme, syntax } = useTheme() return ( - + alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> void @@ -1886,7 +1878,6 @@ function InlineTool(props: { return ( id !== undefined && ctx.userMessageIDs().has(id)} onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -1918,7 +1907,6 @@ function InlineTool(props: { } export function InlineToolRow(props: { - id?: string icon: string iconColor?: RGBA color?: RGBA @@ -1931,32 +1919,20 @@ export function InlineToolRow(props: { pending: string failure?: string spinner?: boolean - subagent?: boolean children: JSX.Element - separateAfter?: (id: string | undefined) => boolean onMouseOver?: () => void onMouseOut?: () => void onMouseUp?: () => void }) { return ( { setPreLayoutSiblingMargin(el, (previous) => { - const previousInline = previous?.id.startsWith("tool-inline-") ?? false - const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false - return previous?.id.startsWith("text-") || - previous?.id.startsWith("tool-block-") || - previous?.id.startsWith("assistant-error-") || - previous?.id.startsWith("assistant-summary-") || - (previousInline && previousSubagent !== Boolean(props.subagent)) || - props.separateAfter?.(previous?.id) - ? 1 - : 0 + return previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous)) ? 1 : 0 }) }} > @@ -2018,7 +1994,7 @@ function BlockTool(props: { const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) return ( alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} @@ -2184,8 +2160,8 @@ function Read(props: ToolProps) { Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])} - {(filepath, index) => ( - + {(filepath) => ( + ↳ Loaded {pathFormatter.format(filepath)} @@ -2305,7 +2281,6 @@ function Task(props: ToolProps) { return ( + alwaysSeparate.add(el)} + marginTop={1} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + gap={1} + > # List files $ ls @@ -65,7 +73,7 @@ function ShellOutput() { function UserMessage() { return ( - + alwaysSeparate.add(el)}> Check whether the next tool remains separated. @@ -88,7 +96,6 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) failed={Boolean(item.error)} error={item.error} errorExpanded={props.errorExpanded} - separateAfter={(id) => id === "message-user"} > {item.label} @@ -99,61 +106,67 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) ) } -function SubagentGroupFixture() { +function TaskRowsFixture() { return ( - + Grep "Task" (2 matches) - + Explore Task — Inspect active task spacing - + {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"} - + Read src/cli/cmd/tui/routes/session/index.tsx ) } -function LoadedReadBeforeSubagentFixture() { +function LoadedReadBeforeTaskFixture() { return ( - + Read src/cli/cmd/tui/routes/session/index.tsx - + ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx - + {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"} ) } -function AssistantSummaryBeforeSubagentFixture() { +function AssistantSummaryBeforeInlineFixture() { return ( - + alwaysSeparate.add(el)} paddingLeft={3}> ▣ Build · Little Frank · 53.1s - + {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} ) } -function AssistantErrorBeforeSubagentFixture() { +function AssistantErrorBeforeInlineFixture() { return ( - + alwaysSeparate.add(el)} + border={["left"]} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + > Managed inference requires an active Member plan - + {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} @@ -170,7 +183,7 @@ function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: Scrol Second row - + alwaysSeparate.add(el)}> Assistant text @@ -200,6 +213,7 @@ function FailedCompleteToolFixture() { async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) { testSetup = await testRender(component, options) await testSetup.renderOnce() + await testSetup.renderOnce() return testSetup .captureCharFrame() @@ -294,22 +308,20 @@ describe("TUI inline tool wrapping", () => { expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot() }) - test("separates a contiguous subagent group from inline tools", async () => { - expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot() + test("separates after a multi-line task row", async () => { + expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot() }) - test("separates a subagent group after an expanded read", async () => { - expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() + test("does not treat task rows differently from other inline rows", async () => { + expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() }) - test("separates a subagent from the previous assistant summary", async () => { - expect( - await renderFrame(() => , { width: 72, height: 5 }), - ).toMatchSnapshot() + test("separates an inline row from the previous assistant summary", async () => { + expect(await renderFrame(() => , { width: 72, height: 5 })).toMatchSnapshot() }) - test("separates a subagent from the previous assistant error", async () => { - expect(await renderFrame(() => , { width: 72, height: 7 })).toMatchSnapshot() + test("separates an inline row from the previous assistant error", async () => { + expect(await renderFrame(() => , { width: 72, height: 7 })).toMatchSnapshot() }) test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => { From babe5070e29e43cc183a8c7acf0af40def343967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=AB=E6=96=AF=E6=9D=8E?= <41602358+Robin1987China@users.noreply.github.com> Date: Sun, 21 Jun 2026 03:19:27 +0800 Subject: [PATCH 013/112] fix(opencode): use toLowerCase for Devstral model detection (#33109) Co-authored-by: Robin1987China --- packages/opencode/src/provider/transform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 92ff8fece83..f78cc53e756 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -218,7 +218,7 @@ function normalizeMessages( if ( model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral") || - model.api.id.toLocaleLowerCase().includes("devstral") + model.api.id.toLowerCase().includes("devstral") ) { const scrub = (id: string) => { return id From 2d993cd0d57ad7e265dea70b314941b8dbd95c79 Mon Sep 17 00:00:00 2001 From: Lucas Kim <41357160+kimnamu@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:35:05 +0900 Subject: [PATCH 014/112] fix(experimental llm pkg): forward topK to Converse via additionalModelRequestFields (#33030) --- .../llm/src/protocols/bedrock-converse.ts | 3 +++ .../test/provider/bedrock-converse.test.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 42dcaef5400..80412ca9ddb 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -412,6 +412,9 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: stopSequences: generation?.stop, }, toolConfig, + // Converse's base inferenceConfig has no topK; Anthropic/Nova accept it + // as a model-specific field, so it goes through additionalModelRequestFields. + additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK }, } }) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 23483dda251..46657331a34 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -83,6 +83,26 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("passes topK through additionalModelRequestFields as top_k", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }), + ) + + // Converse's inferenceConfig has no topK; Anthropic/Nova read it from + // additionalModelRequestFields as top_k. + expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 }) + expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 }) + }), + ) + + it.effect("omits additionalModelRequestFields when topK is unset", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + expect(prepared.body.additionalModelRequestFields).toBeUndefined() + }), + ) + it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( From 24c70ec9740da2a509d6291943afca1dc3cfdb2f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:54:15 -0500 Subject: [PATCH 015/112] feat(stats): add unique user charts --- .../stats/app/src/routes/[lab]/[model].tsx | 230 +++++++++++------- packages/stats/app/src/routes/index.css | 78 ++++-- packages/stats/app/src/routes/index.tsx | 58 ++++- .../20260620000000_unique_users/migration.sql | 3 + packages/stats/core/src/database/schema.ts | 1 + packages/stats/core/src/domain/geo.ts | 1 + packages/stats/core/src/domain/home.ts | 45 +++- packages/stats/core/src/domain/inference.ts | 5 + packages/stats/core/src/domain/model.ts | 3 + packages/stats/core/src/domain/provider.ts | 1 + packages/stats/core/src/domain/stat.ts | 4 + packages/stats/core/src/honeycomb-backfill.ts | 17 +- packages/stats/core/src/stat-sync.ts | 8 +- 13 files changed, 327 insertions(+), 127 deletions(-) create mode 100644 packages/stats/core/migrations/20260620000000_unique_users/migration.sql diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 92f99fe7d3e..a298a7cbbb6 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -45,6 +45,7 @@ const statsUnfurlUrl = new URL(statsUnfurlPath, statsCanonicalBaseUrl).toString( const modelHeaderLinks: readonly HeaderLink[] = [ { href: "#overview", label: "Overview" }, { href: "#usage", label: "Usage" }, + { href: "#users", label: "Users" }, { href: "#efficiency", label: "Efficiency" }, { href: "#geo-breakdown", label: "Geo Breakdown" }, { href: "#peers", label: "Peers" }, @@ -175,6 +176,7 @@ export default function StatsModel() { + @@ -358,14 +360,6 @@ function ModelOverview(props: { data: StatsModelData | null }) { } function ModelUsageSection(props: { data: ModelUsagePoint[] }) { - const [activeIndex, setActiveIndex] = createSignal() - const max = createMemo(() => Math.max(0, ...props.data.map((item) => item.tokens)) || 1) - const activePoint = createMemo(() => { - const index = activeIndex() - if (index === undefined) return undefined - return props.data[index] - }) - return (
@@ -373,92 +367,144 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) { when={props.data.some((item) => item.tokens > 0)} fallback={} > -
{ - if (event.pointerType === "touch") return - setActiveIndex(undefined) - }} - > - -
- - {(point, index) => ( -
{ - if (event.pointerType !== "touch") return - setActiveIndex(index()) - }} - onPointerEnter={() => setActiveIndex(index())} - onPointerMove={(event) => { - if (event.pointerType === "touch") return - setActiveIndex(index()) - }} - onClick={() => setActiveIndex(index())} - onFocus={() => setActiveIndex(index())} - onBlur={() => setActiveIndex(undefined)} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - setActiveIndex(index()) - }} - > -
- - {(active) => ( -
props.data.length * 0.62 ? "left" : "right"} - > - {active().date} - {formatTokens(active().tokens)} tokens -
-

- - Daily tokens - - {formatTokens(active().tokens)} -

-
- )} - -
- )} - -
-
+
) } +function ModelUsersSection(props: { data: ModelUsagePoint[] }) { + return ( +
+ + item.users > 0)} + fallback={} + > + + +
+ ) +} + +function ModelColumnChart(props: { + data: ModelUsagePoint[] + metric: "tokens" | "users" + ariaLabel: string +}) { + const [activeIndex, setActiveIndex] = createSignal() + const max = createMemo(() => Math.max(0, ...props.data.map((item) => modelUsageMetricValue(item, props.metric))) || 1) + const activePoint = createMemo(() => { + const index = activeIndex() + if (index === undefined) return undefined + return props.data[index] + }) + + return ( +
{ + if (event.pointerType === "touch") return + setActiveIndex(undefined) + }} + > + +
+ + {(point, index) => ( +
{ + if (event.pointerType !== "touch") return + setActiveIndex(index()) + }} + onPointerEnter={() => setActiveIndex(index())} + onPointerMove={(event) => { + if (event.pointerType === "touch") return + setActiveIndex(index()) + }} + onClick={() => setActiveIndex(index())} + onFocus={() => setActiveIndex(index())} + onBlur={() => setActiveIndex(undefined)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + setActiveIndex(index()) + }} + > +
+ + {(active) => ( +
props.data.length * 0.62 ? "left" : "right"} + > + {active().date} + + {formatModelUsageValue(active(), props.metric)} {modelUsageLabel(props.metric)} + +
+

+ + Daily {modelUsageLabel(props.metric)} + + {formatModelUsageValue(active(), props.metric)} +

+
+ )} + +
+ )} + +
+
+ ) +} + +function modelUsageMetricValue(point: ModelUsagePoint, metric: "tokens" | "users") { + if (metric === "users") return point.users + return point.tokens +} + +function formatModelUsageValue(point: ModelUsagePoint, metric: "tokens" | "users") { + if (metric === "users") return formatUsers(point.users) + return formatTokens(point.tokens) +} + +function modelUsageLabel(metric: "tokens" | "users") { + if (metric === "users") return "users" + return "tokens" +} + function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) { return (
@@ -834,6 +880,12 @@ function formatInteger(value: number) { return new Intl.NumberFormat("en").format(value) } +function formatUsers(value: number) { + if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M` + if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K` + return formatInteger(Math.round(value)) +} + function formatPercent(value: number) { return `${value.toFixed(value > 0 && value < 10 ? 1 : 0)}%` } diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index f6c7f7bef45..05569c1b885 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -1546,7 +1546,7 @@ color: var(--stats-text); } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] { +[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] { top: 110px; box-sizing: border-box; display: flex; @@ -1564,40 +1564,53 @@ color: var(--stats-text); } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement="right"] { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"][data-placement="right"] { right: auto; left: calc(100% + 8px); } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement="left"] { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"][data-placement="left"] { right: calc(100% + 8px); left: auto; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] strong, -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] > span { +[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong, +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + > span { display: block; font-size: 11px; line-height: 12px; white-space: nowrap; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] strong { +[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong { padding: 8px 8px 0; font-weight: 500; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] > span { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + > span { padding: 4px 8px 8px; color: var(--stats-muted); } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + [data-slot="tooltip-divider"] { height: 0.5px; margin: 0; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p { +[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] p { grid-template-columns: minmax(0, 1fr) auto; gap: 4px; height: 16px; @@ -1608,36 +1621,50 @@ line-height: 12px; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p[data-muted="true"] { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + p[data-muted="true"] { opacity: 0.46; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] + p { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + [data-slot="tooltip-divider"] + + p { margin-top: 8px; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p:last-child { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + p:last-child { margin-bottom: 8px; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-label"] { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + [data-slot="tooltip-label"] { grid-template-columns: 16px minmax(0, 1fr); gap: 4px; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] i { +[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] i { width: 6px; height: 6px; justify-self: center; } -[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] b { +[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] b { font-weight: 500; } [data-page="stats"] :is( [data-section="leaderboard"], + [data-section="unique-users"], [data-section="market-share"], [data-section="geo-breakdown"], [data-section="token-cost"], @@ -3264,10 +3291,12 @@ background: #242424f2; } -[data-page="stats"][data-theme="dark"] [data-section="top-models"] [data-component="chart-tooltip"], +[data-page="stats"][data-theme="dark"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"], :root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) - [data-section="top-models"] + :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] { background: #242424f2; box-shadow: @@ -3276,10 +3305,13 @@ 0 4px 8px #00000052; } -[data-page="stats"][data-theme="dark"] [data-section="top-models"] [data-component="chart-tooltip"] > span, +[data-page="stats"][data-theme="dark"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + > span, :root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) - [data-section="top-models"] + :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] > span { color: var(--stats-faint); @@ -3544,6 +3576,7 @@ @media (max-width: 74rem) { [data-page="stats"] [data-section="top-models"], [data-page="stats"] [data-section="leaderboard"], + [data-page="stats"] [data-section="unique-users"], [data-page="stats"] [data-section="market-share"], [data-page="stats"] [data-section="geo-breakdown"], [data-page="stats"] [data-section="token-cost"], @@ -3703,6 +3736,7 @@ @media (max-width: 47.999rem) { [data-page="stats"] [data-section="top-models"], [data-page="stats"] [data-section="leaderboard"], + [data-page="stats"] [data-section="unique-users"], [data-page="stats"] [data-section="market-share"], [data-page="stats"] [data-section="geo-breakdown"], [data-page="stats"] [data-section="token-cost"], @@ -4011,7 +4045,7 @@ display: block; } - [data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] { + [data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] { position: fixed; top: auto; right: 12px; @@ -4025,7 +4059,9 @@ transform: none; } - [data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement] { + [data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"][data-placement] { right: 12px; left: 12px; } diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 7f528015a0f..cdef00b9488 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -171,6 +171,7 @@ export default function StatsHome() { <> + @@ -598,6 +599,8 @@ function FilterPills(props: { function TopModelsChart(props: { data: UsagePoint[] range: UsageRange + metric?: "tokens" | "users" + ariaLabel?: string activeModel: string | undefined onActiveModelChange: (model: string | undefined) => void }) { @@ -606,6 +609,7 @@ function TopModelsChart(props: { const maxTotal = createMemo(() => getTopModelsMaxTotal(props.data)) const segmentOrder = createMemo(() => getTopModelsSegmentOrder(props.data)) const activePoint = createMemo(() => props.data[activeIndex() ?? -1]) + const metric = createMemo(() => props.metric ?? "tokens") createEffect(() => scrollDenseChartToEnd(chartRef, props.range, props.data.length)) @@ -614,9 +618,10 @@ function TopModelsChart(props: { ref={chartRef} data-component="top-models-chart" data-range={props.range} + data-metric={metric()} data-dense-labels={isDenseColumnRange(props.range) ? "true" : undefined} role="img" - aria-label="Stacked top model usage chart" + aria-label={props.ariaLabel ?? "Stacked top model usage chart"} style={{ "--top-models-count": props.data.length } as JSX.CSSProperties} onPointerLeave={(event) => { if (event.pointerType === "touch") return @@ -633,7 +638,7 @@ function TopModelsChart(props: { data-mobile-hidden={isTopModelsMobileAxisHidden(index(), props.data.length) ? "true" : undefined} > - {formatTokens(usageTotal(day))} + {formatUsageChartValue(usageTotal(day), metric())} {day.date} {formatTopModelsMobileDate(day.date, props.range)} @@ -657,7 +662,7 @@ function TopModelsChart(props: { data-slot="top-models-bar" role="button" tabIndex={0} - aria-label={`${day.date} ${formatTokens(usageTotal(day))}`} + aria-label={`${day.date} ${formatUsageChartValue(usageTotal(day), metric())} ${usageChartTotalLabel(metric())}`} data-active={activeIndex() === dayIndex() ? "true" : undefined} data-muted={activeIndex() !== undefined && activeIndex() !== dayIndex() ? "true" : undefined} style={{ "--top-models-bar-height": `${getTopModelsBarHeight(usageTotal(day), maxTotal())}%` }} @@ -739,7 +744,9 @@ function TopModelsChart(props: { data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"} > {point().date} - {formatTokens(usageTotal(point()))} total + + {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} +
{(item) => ( @@ -759,7 +766,7 @@ function TopModelsChart(props: { />{" "} {item.segment.model} - {formatTokens(item.segment.value)} + {formatUsageChartValue(item.segment.value, metric())}

)}
@@ -774,6 +781,31 @@ function TopModelsChart(props: { ) } +function UniqueUsersSection(props: { data: StatsHomeData["users"] }) { + const [activeModel, setActiveModel] = createSignal() + const data = createMemo(() => props.data.Go["2M"]) + + return ( +
+ + + usageTotal(item) > 0)} + fallback={} + > + + +
+ ) +} + function isTopModelsBlankHover(bar: HTMLElement, clientY: number) { const stack = bar.querySelector('[data-slot="top-models-stack"]') if (!stack) return true @@ -864,6 +896,22 @@ function formatTokens(value: number) { return `${Math.round(value * 1000)}B` } +function formatUsageChartValue(value: number, metric: "tokens" | "users") { + if (metric === "users") return formatUsers(value) + return formatTokens(value) +} + +function usageChartTotalLabel(metric: "tokens" | "users") { + if (metric === "users") return "model users" + return "total" +} + +function formatUsers(value: number) { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K` + return new Intl.NumberFormat("en").format(Math.round(value)) +} + function Leaderboard(props: { data: LeaderboardEntry[] activeModel: string | undefined diff --git a/packages/stats/core/migrations/20260620000000_unique_users/migration.sql b/packages/stats/core/migrations/20260620000000_unique_users/migration.sql new file mode 100644 index 00000000000..3b9784b0dc4 --- /dev/null +++ b/packages/stats/core/migrations/20260620000000_unique_users/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `geo_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;--> statement-breakpoint +ALTER TABLE `model_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;--> statement-breakpoint +ALTER TABLE `provider_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0; diff --git a/packages/stats/core/src/database/schema.ts b/packages/stats/core/src/database/schema.ts index a658a4d511c..d5bfa314bfd 100644 --- a/packages/stats/core/src/database/schema.ts +++ b/packages/stats/core/src/database/schema.ts @@ -123,6 +123,7 @@ function metricColumns() { return { sessions: bigint({ mode: "number" }).notNull().default(0), requests: bigint({ mode: "number" }).notNull().default(0), + unique_users: bigint({ mode: "number" }).notNull().default(0), input_tokens: bigint({ mode: "number" }).notNull().default(0), output_tokens: bigint({ mode: "number" }).notNull().default(0), reasoning_tokens: bigint({ mode: "number" }).notNull().default(0), diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts index c58195c7795..99c7bcc4acb 100644 --- a/packages/stats/core/src/domain/geo.ts +++ b/packages/stats/core/src/domain/geo.ts @@ -145,6 +145,7 @@ export class GeoStatRepo extends Context.Service> + users: Record> leaderboard: Record> market: Record tokenCost: Record @@ -118,6 +119,7 @@ type ModelAggregate = { model: string provider: string sessions: number + uniqueUsers: number inputTokens: number outputTokens: number reasoningTokens: number @@ -200,6 +202,18 @@ function buildStatsHomeData( ), ), ), + users: createUsageProductRecord((product) => + createRangeRecord((range) => + buildUsagePoints( + normalized, + product, + range, + getWindow(range, earliest, latest), + getWindow("1W", earliest, latest), + "users", + ), + ), + ), leaderboard: createUsageProductRecord((product) => createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow("1W", earliest, latest))), ), @@ -340,6 +354,7 @@ function emptyStatsHomeData(): StatsHomeData { return { updatedAt: null, usage: createUsageProductRecord(() => createRangeRecord(() => [])), + users: createUsageProductRecord(() => createRangeRecord(() => [])), leaderboard: createUsageProductRecord(() => createRangeRecord(() => [])), market: createRangeRecord(() => []), tokenCost: createTokenProductRecord(() => []), @@ -355,28 +370,39 @@ function buildUsagePoints( range: UsageRange, window: DateWindow, rankWindow: DateWindow, + metric: "tokens" | "users" = "tokens", ) { const modelOrder = aggregateByModelName(rowsForProduct(rows, product, rankWindow.start, rankWindow.end)) - .toSorted((a, b) => b.totalTokens - a.totalTokens) + .toSorted((a, b) => modelUsageValue(b, metric) - modelUsageValue(a, metric)) .slice(0, TOP_MODEL_SEGMENT_LIMIT) .map((item) => item.model) return createBuckets(window, range).map((bucket) => { const bucketRows = aggregateByModelName(rowsForProduct(rows, product, bucket.start, bucket.end)) - const byModel = new Map(bucketRows.map((item) => [item.model, item.totalTokens])) - const segmentTokens = modelOrder.map((model) => ({ model, tokens: byModel.get(model) ?? 0 })) - const knownTokens = segmentTokens.reduce((sum, item) => sum + item.tokens, 0) - const totalTokens = bucketRows.reduce((sum, item) => sum + item.totalTokens, 0) + const byModel = new Map(bucketRows.map((item) => [item.model, modelUsageValue(item, metric)])) + const segments = modelOrder.map((model) => ({ model, value: byModel.get(model) ?? 0 })) + const knownValue = segments.reduce((sum, item) => sum + item.value, 0) + const totalValue = bucketRows.reduce((sum, item) => sum + modelUsageValue(item, metric), 0) return { date: bucket.label, segments: [ - ...segmentTokens.map((item) => ({ model: item.model, value: round(item.tokens / 1_000_000_000_000, 4) })), - { model: "Other", value: round(Math.max(totalTokens - knownTokens, 0) / 1_000_000_000_000, 4) }, + ...segments.map((item) => ({ model: item.model, value: usagePointValue(item.value, metric) })), + { model: "Other", value: usagePointValue(Math.max(totalValue - knownValue, 0), metric) }, ], } }) } +function modelUsageValue(item: ModelAggregate, metric: "tokens" | "users") { + if (metric === "users") return item.uniqueUsers + return item.totalTokens +} + +function usagePointValue(value: number, metric: "tokens" | "users") { + if (metric === "users") return value + return round(value / 1_000_000_000_000, 4) +} + function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, rankWindow: DateWindow) { const previous = new Map( aggregateByModelName(rowsForProduct(rows, product, rankWindow.previousStart, rankWindow.previousEnd)).map( @@ -502,6 +528,7 @@ function buildModelUsage(rows: StatMetricRow[], window: DateWindow, range: Usage return { date: bucket.label, tokens: aggregate.totalTokens, + users: aggregate.uniqueUsers, sessions: aggregate.sessions, cost: round(microcentsToDollars(aggregate.totalCostMicrocents), 2), } @@ -601,6 +628,7 @@ function combineRowsForModel(model: string, rows: StatMetricRow[]): ModelAggrega model, provider: "unknown", sessions: 0, + uniqueUsers: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, @@ -617,6 +645,7 @@ function combineModelAggregate(current: ModelAggregate | undefined, row: StatMet model: row.model, provider: row.provider, sessions: (current?.sessions ?? 0) + row.sessions, + uniqueUsers: (current?.uniqueUsers ?? 0) + row.uniqueUsers, inputTokens: (current?.inputTokens ?? 0) + row.inputTokens, outputTokens: (current?.outputTokens ?? 0) + row.outputTokens, reasoningTokens: (current?.reasoningTokens ?? 0) + row.reasoningTokens, diff --git a/packages/stats/core/src/domain/inference.ts b/packages/stats/core/src/domain/inference.ts index 9a74daf958e..a7e4c0037e8 100644 --- a/packages/stats/core/src/domain/inference.ts +++ b/packages/stats/core/src/domain/inference.ts @@ -40,6 +40,7 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S const aggregateColumns = ` COUNT(DISTINCT session) AS sessions, COUNT(*) AS requests, + COUNT(DISTINCT user_key) AS unique_users, COALESCE(SUM(tokens_input), 0) AS input_tokens, COALESCE(SUM(tokens_output), 0) AS output_tokens, COALESCE(SUM(tokens_reasoning), 0) AS reasoning_tokens, @@ -70,6 +71,8 @@ WITH normalized AS ( UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country, COALESCE(NULLIF(cf_continent, ''), '') AS continent, session, + COALESCE(NULLIF(workspace, ''), '') AS workspace, + COALESCE(NULLIF(api_key, ''), '') AS api_key, status, duration AS duration_ms, time_to_first_byte AS ttfb_ms, @@ -108,6 +111,7 @@ WITH normalized AS ( country, continent, session, + COALESCE(NULLIF(workspace, ''), NULLIF(api_key, '')) AS user_key, status, duration_ms, ttfb_ms, @@ -197,6 +201,7 @@ function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] { tier: normalizeTier(data.tier || "unknown"), sessions: integer(data, "sessions"), requests: integer(data, "requests"), + unique_users: integer(data, "unique_users"), input_tokens: integer(data, "input_tokens"), output_tokens: integer(data, "output_tokens"), reasoning_tokens: integer(data, "reasoning_tokens"), diff --git a/packages/stats/core/src/domain/model.ts b/packages/stats/core/src/domain/model.ts index 0d40a51c501..912ee629046 100644 --- a/packages/stats/core/src/domain/model.ts +++ b/packages/stats/core/src/domain/model.ts @@ -27,6 +27,7 @@ export type ModelStatMetric = { provider: string model: string sessions: number + uniqueUsers: number inputTokens: number outputTokens: number reasoningTokens: number @@ -64,6 +65,7 @@ export class ModelStatRepo extends Context.Service(left: T, right: T): T { ...left, sessions: (left.sessions ?? 0) + (right.sessions ?? 0), requests: (left.requests ?? 0) + (right.requests ?? 0), + unique_users: (left.unique_users ?? 0) + (right.unique_users ?? 0), input_tokens: (left.input_tokens ?? 0) + (right.input_tokens ?? 0), output_tokens: (left.output_tokens ?? 0) + (right.output_tokens ?? 0), reasoning_tokens: (left.reasoning_tokens ?? 0) + (right.reasoning_tokens ?? 0), diff --git a/packages/stats/core/src/honeycomb-backfill.ts b/packages/stats/core/src/honeycomb-backfill.ts index 72f9c874eb9..5a2b25fa4c7 100644 --- a/packages/stats/core/src/honeycomb-backfill.ts +++ b/packages/stats/core/src/honeycomb-backfill.ts @@ -242,6 +242,7 @@ function metricQuery(breakdowns: string[], limit: number, filters: ReturnType) { - return ["sumtokens", "sumtokensinput", "inputtokens", "totaltokens", "avgduration", "countdistinctsession"].some( - (header) => headers.has(header), - ) + return [ + "sumtokens", + "sumtokensinput", + "inputtokens", + "totaltokens", + "avgduration", + "countdistinctsession", + "countdistinctworkspace", + ].some((header) => headers.has(header)) } function hasHeader(headers: Set, names: string[]) { @@ -447,6 +454,7 @@ function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBase tier: tier(row), sessions: integer(row, "sessions", ["COUNT_DISTINCT(session)"]), requests: integer(row, "requests", ["COUNT", "COUNT()"]), + unique_users: integer(row, "unique_users", ["COUNT_DISTINCT(workspace)", "COUNT_DISTINCT(api_key)"]), input_tokens: integer(row, "input_tokens", ["SUM(tokens.input)", "SUM(tokens_input)"]), output_tokens: integer(row, "output_tokens", ["SUM(tokens.output)", "SUM(tokens_output)"]), reasoning_tokens: integer(row, "reasoning_tokens", ["SUM(tokens.reasoning)", "SUM(tokens_reasoning)"]), @@ -808,6 +816,7 @@ async function upsertModelRows(db: ReturnType, rows: ModelStatRo provider_model: inserted("provider_model"), sessions: inserted("sessions"), requests: inserted("requests"), + unique_users: inserted("unique_users"), input_tokens: inserted("input_tokens"), output_tokens: inserted("output_tokens"), reasoning_tokens: inserted("reasoning_tokens"), @@ -845,6 +854,7 @@ async function upsertProviderRows(db: ReturnType, rows: Provider set: { sessions: inserted("sessions"), requests: inserted("requests"), + unique_users: inserted("unique_users"), input_tokens: inserted("input_tokens"), output_tokens: inserted("output_tokens"), reasoning_tokens: inserted("reasoning_tokens"), @@ -887,6 +897,7 @@ async function upsertGeoRows(db: ReturnType, rows: GeoStatRow[], continent: inserted("continent"), sessions: inserted("sessions"), requests: inserted("requests"), + unique_users: inserted("unique_users"), input_tokens: inserted("input_tokens"), output_tokens: inserted("output_tokens"), reasoning_tokens: inserted("reasoning_tokens"), diff --git a/packages/stats/core/src/stat-sync.ts b/packages/stats/core/src/stat-sync.ts index df0a317d6cf..86e5ded7deb 100644 --- a/packages/stats/core/src/stat-sync.ts +++ b/packages/stats/core/src/stat-sync.ts @@ -11,6 +11,7 @@ import { startOfIsoWeek } from "./domain/stat" const DATALAKE_INGESTION_LAG_MS = 5 * 60_000 const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime() const WEEK_MS = 7 * 86_400_000 +const DISPLAY_WINDOW_MS = 56 * 86_400_000 export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string } export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError @@ -23,7 +24,12 @@ export const syncStats: () => Effect.Effect< const startedAt = yield* DateTime.nowAsDate const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000) // May 27 was partial, so keep Athena stats anchored at the first complete day. - const periodStart = new Date(Math.max(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, STATS_DATA_START_MS)) + const periodStart = new Date( + Math.max( + Math.min(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, periodEnd.getTime() - DISPLAY_WINDOW_MS), + STATS_DATA_START_MS, + ), + ) const athena = yield* Athena const modelStats = yield* ModelStatRepo const providerStats = yield* ProviderStatRepo From 1c76587ce2057893c5f2ad24b0955bb2ef982329 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 20 Jun 2026 19:56:12 +0000 Subject: [PATCH 016/112] chore: generate --- .../stats/app/src/routes/[lab]/[model].tsx | 23 +++++++++++-------- packages/stats/app/src/routes/index.css | 10 ++++++-- packages/stats/app/src/routes/index.tsx | 4 +++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index a298a7cbbb6..6869764f333 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -376,10 +376,15 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) { function ModelUsersSection(props: { data: ModelUsagePoint[] }) { return (
- + item.users > 0)} - fallback={} + fallback={ + + } > @@ -387,11 +392,7 @@ function ModelUsersSection(props: { data: ModelUsagePoint[] }) { ) } -function ModelColumnChart(props: { - data: ModelUsagePoint[] - metric: "tokens" | "users" - ariaLabel: string -}) { +function ModelColumnChart(props: { data: ModelUsagePoint[]; metric: "tokens" | "users"; ariaLabel: string }) { const [activeIndex, setActiveIndex] = createSignal() const max = createMemo(() => Math.max(0, ...props.data.map((item) => modelUsageMetricValue(item, props.metric))) || 1) const activePoint = createMemo(() => { @@ -458,9 +459,11 @@ function ModelColumnChart(props: { >
{(active) => ( diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 05569c1b885..01b561c526d 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -1578,7 +1578,10 @@ left: auto; } -[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong, +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + strong, [data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] @@ -1589,7 +1592,10 @@ white-space: nowrap; } -[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong { +[data-page="stats"] + :is([data-section="top-models"], [data-section="unique-users"]) + [data-component="chart-tooltip"] + strong { padding: 8px 8px 0; font-weight: 500; } diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index cdef00b9488..862120d033f 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -791,7 +791,9 @@ function UniqueUsersSection(props: { data: StatsHomeData["users"] }) { usageTotal(item) > 0)} - fallback={} + fallback={ + + } > Date: Sat, 20 Jun 2026 15:56:36 -0500 Subject: [PATCH 017/112] fix(stats): tolerate pending user column --- packages/stats/core/src/domain/geo.ts | 86 ++++++----- packages/stats/core/src/domain/model.ts | 165 +++++++++++++-------- packages/stats/core/src/domain/provider.ts | 84 ++++++----- packages/stats/core/src/domain/stat.ts | 21 +++ 4 files changed, 221 insertions(+), 135 deletions(-) diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts index 99c7bcc4acb..b75e08a34b2 100644 --- a/packages/stats/core/src/domain/geo.ts +++ b/packages/stats/core/src/domain/geo.ts @@ -8,6 +8,8 @@ import { chunks, collapseRows, inserted, + isMissingUniqueUsersColumn, + omitUniqueUsers, rankRowsWithMarketShare, statPeriodKey, statRowScope, @@ -136,49 +138,59 @@ export class GeoStatRepo extends Context.Service Effect.tryPromise({ - try: () => - db - .insert(geoStat) - .values(chunk) - .onDuplicateKeyUpdate({ - set: { - continent: inserted("continent"), - sessions: inserted("sessions"), - requests: inserted("requests"), - unique_users: inserted("unique_users"), - input_tokens: inserted("input_tokens"), - output_tokens: inserted("output_tokens"), - reasoning_tokens: inserted("reasoning_tokens"), - cache_read_tokens: inserted("cache_read_tokens"), - total_tokens: inserted("total_tokens"), - input_cost_microcents: inserted("input_cost_microcents"), - output_cost_microcents: inserted("output_cost_microcents"), - total_cost_microcents: inserted("total_cost_microcents"), - avg_duration_ms: inserted("avg_duration_ms"), - p50_duration_ms: inserted("p50_duration_ms"), - p95_duration_ms: inserted("p95_duration_ms"), - avg_ttfb_ms: inserted("avg_ttfb_ms"), - p50_ttfb_ms: inserted("p50_ttfb_ms"), - p95_ttfb_ms: inserted("p95_ttfb_ms"), - avg_output_tps: inserted("avg_output_tps"), - success_count: inserted("success_count"), - error_count: inserted("error_count"), - sample_count: inserted("sample_count"), - market_share_tokens: inserted("market_share_tokens"), - market_share_requests: inserted("market_share_requests"), - market_share_sessions: inserted("market_share_sessions"), - rank_by_tokens: inserted("rank_by_tokens"), - rank_by_requests: inserted("rank_by_requests"), - rank_by_sessions: inserted("rank_by_sessions"), - rank_by_cost: inserted("rank_by_cost"), - }, - }), + try: async () => { + try { + return await upsertGeoChunk(chunk, true) + } catch (cause) { + if (!isMissingUniqueUsersColumn(cause)) throw cause + return upsertGeoChunk(chunk, false) + } + }, catch: (cause) => DatabaseError.make({ cause }), }), { discard: true }, ) }) + function upsertGeoChunk(chunk: GeoStatRow[], includeUniqueUsers: boolean) { + return db + .insert(geoStat) + .values(includeUniqueUsers ? chunk : omitUniqueUsers(chunk)) + .onDuplicateKeyUpdate({ + set: { + continent: inserted("continent"), + sessions: inserted("sessions"), + requests: inserted("requests"), + ...(includeUniqueUsers ? { unique_users: inserted("unique_users") } : {}), + input_tokens: inserted("input_tokens"), + output_tokens: inserted("output_tokens"), + reasoning_tokens: inserted("reasoning_tokens"), + cache_read_tokens: inserted("cache_read_tokens"), + total_tokens: inserted("total_tokens"), + input_cost_microcents: inserted("input_cost_microcents"), + output_cost_microcents: inserted("output_cost_microcents"), + total_cost_microcents: inserted("total_cost_microcents"), + avg_duration_ms: inserted("avg_duration_ms"), + p50_duration_ms: inserted("p50_duration_ms"), + p95_duration_ms: inserted("p95_duration_ms"), + avg_ttfb_ms: inserted("avg_ttfb_ms"), + p50_ttfb_ms: inserted("p50_ttfb_ms"), + p95_ttfb_ms: inserted("p95_ttfb_ms"), + avg_output_tps: inserted("avg_output_tps"), + success_count: inserted("success_count"), + error_count: inserted("error_count"), + sample_count: inserted("sample_count"), + market_share_tokens: inserted("market_share_tokens"), + market_share_requests: inserted("market_share_requests"), + market_share_sessions: inserted("market_share_sessions"), + rank_by_tokens: inserted("rank_by_tokens"), + rank_by_requests: inserted("rank_by_requests"), + rank_by_sessions: inserted("rank_by_sessions"), + rank_by_cost: inserted("rank_by_cost"), + }, + }) + } + const deleteRetiredDimensions = Effect.fn("GeoStatRepo.deleteRetiredDimensions")(function* (rows: GeoStatRow[]) { const scope = statRowScope(rows) if (!scope) return diff --git a/packages/stats/core/src/domain/model.ts b/packages/stats/core/src/domain/model.ts index 912ee629046..5cdee912b76 100644 --- a/packages/stats/core/src/domain/model.ts +++ b/packages/stats/core/src/domain/model.ts @@ -8,6 +8,8 @@ import { chunks, collapseRows, inserted, + isMissingUniqueUsersColumn, + omitUniqueUsers, rankBy, statPeriodKey, statRowScope, @@ -56,35 +58,55 @@ export class ModelStatRepo extends Context.Service - db - .select({ - periodKey: modelStat.period_key, - updatedAt: modelStat.updated_at, - tier: modelStat.tier, - provider: modelStat.provider, - model: modelStat.model, - sessions: modelStat.sessions, - uniqueUsers: modelStat.unique_users, - inputTokens: modelStat.input_tokens, - outputTokens: modelStat.output_tokens, - reasoningTokens: modelStat.reasoning_tokens, - cacheReadTokens: modelStat.cache_read_tokens, - totalTokens: modelStat.total_tokens, - inputCostMicrocents: modelStat.input_cost_microcents, - outputCostMicrocents: modelStat.output_cost_microcents, - totalCostMicrocents: modelStat.total_cost_microcents, - }) - .from(modelStat) - .where( - and( - eq(modelStat.grain, "day"), - eq(modelStat.client, "all"), - eq(modelStat.source, "all"), - inArray(modelStat.tier, ["Go", "go"]), - ), - ) - .orderBy(asc(modelStat.period_key)), + try: async () => { + try { + return await db + .select({ + periodKey: modelStat.period_key, + updatedAt: modelStat.updated_at, + tier: modelStat.tier, + provider: modelStat.provider, + model: modelStat.model, + sessions: modelStat.sessions, + uniqueUsers: modelStat.unique_users, + inputTokens: modelStat.input_tokens, + outputTokens: modelStat.output_tokens, + reasoningTokens: modelStat.reasoning_tokens, + cacheReadTokens: modelStat.cache_read_tokens, + totalTokens: modelStat.total_tokens, + inputCostMicrocents: modelStat.input_cost_microcents, + outputCostMicrocents: modelStat.output_cost_microcents, + totalCostMicrocents: modelStat.total_cost_microcents, + }) + .from(modelStat) + .where(modelDailyScope()) + .orderBy(asc(modelStat.period_key)) + } catch (cause) { + if (!isMissingUniqueUsersColumn(cause)) throw cause + return ( + await db + .select({ + periodKey: modelStat.period_key, + updatedAt: modelStat.updated_at, + tier: modelStat.tier, + provider: modelStat.provider, + model: modelStat.model, + sessions: modelStat.sessions, + inputTokens: modelStat.input_tokens, + outputTokens: modelStat.output_tokens, + reasoningTokens: modelStat.reasoning_tokens, + cacheReadTokens: modelStat.cache_read_tokens, + totalTokens: modelStat.total_tokens, + inputCostMicrocents: modelStat.input_cost_microcents, + outputCostMicrocents: modelStat.output_cost_microcents, + totalCostMicrocents: modelStat.total_cost_microcents, + }) + .from(modelStat) + .where(modelDailyScope()) + .orderBy(asc(modelStat.period_key)) + ).map((row) => ({ ...row, uniqueUsers: 0 })) + } + }, catch: (cause) => DatabaseError.make({ cause }), }) }) @@ -94,45 +116,55 @@ export class ModelStatRepo extends Context.Service Effect.tryPromise({ - try: () => - db - .insert(modelStat) - .values(chunk) - .onDuplicateKeyUpdate({ - set: { - provider_model: inserted("provider_model"), - sessions: inserted("sessions"), - requests: inserted("requests"), - unique_users: inserted("unique_users"), - input_tokens: inserted("input_tokens"), - output_tokens: inserted("output_tokens"), - reasoning_tokens: inserted("reasoning_tokens"), - cache_read_tokens: inserted("cache_read_tokens"), - total_tokens: inserted("total_tokens"), - input_cost_microcents: inserted("input_cost_microcents"), - output_cost_microcents: inserted("output_cost_microcents"), - total_cost_microcents: inserted("total_cost_microcents"), - avg_duration_ms: inserted("avg_duration_ms"), - p50_duration_ms: inserted("p50_duration_ms"), - p95_duration_ms: inserted("p95_duration_ms"), - avg_ttfb_ms: inserted("avg_ttfb_ms"), - p50_ttfb_ms: inserted("p50_ttfb_ms"), - p95_ttfb_ms: inserted("p95_ttfb_ms"), - avg_output_tps: inserted("avg_output_tps"), - success_count: inserted("success_count"), - error_count: inserted("error_count"), - sample_count: inserted("sample_count"), - rank_by_tokens: inserted("rank_by_tokens"), - rank_by_requests: inserted("rank_by_requests"), - rank_by_cost: inserted("rank_by_cost"), - }, - }), + try: async () => { + try { + return await upsertModelChunk(chunk, true) + } catch (cause) { + if (!isMissingUniqueUsersColumn(cause)) throw cause + return upsertModelChunk(chunk, false) + } + }, catch: (cause) => DatabaseError.make({ cause }), }), { discard: true }, ) }) + function upsertModelChunk(chunk: ModelStatRow[], includeUniqueUsers: boolean) { + return db + .insert(modelStat) + .values(includeUniqueUsers ? chunk : omitUniqueUsers(chunk)) + .onDuplicateKeyUpdate({ + set: { + provider_model: inserted("provider_model"), + sessions: inserted("sessions"), + requests: inserted("requests"), + ...(includeUniqueUsers ? { unique_users: inserted("unique_users") } : {}), + input_tokens: inserted("input_tokens"), + output_tokens: inserted("output_tokens"), + reasoning_tokens: inserted("reasoning_tokens"), + cache_read_tokens: inserted("cache_read_tokens"), + total_tokens: inserted("total_tokens"), + input_cost_microcents: inserted("input_cost_microcents"), + output_cost_microcents: inserted("output_cost_microcents"), + total_cost_microcents: inserted("total_cost_microcents"), + avg_duration_ms: inserted("avg_duration_ms"), + p50_duration_ms: inserted("p50_duration_ms"), + p95_duration_ms: inserted("p95_duration_ms"), + avg_ttfb_ms: inserted("avg_ttfb_ms"), + p50_ttfb_ms: inserted("p50_ttfb_ms"), + p95_ttfb_ms: inserted("p95_ttfb_ms"), + avg_output_tps: inserted("avg_output_tps"), + success_count: inserted("success_count"), + error_count: inserted("error_count"), + sample_count: inserted("sample_count"), + rank_by_tokens: inserted("rank_by_tokens"), + rank_by_requests: inserted("rank_by_requests"), + rank_by_cost: inserted("rank_by_cost"), + }, + }) + } + const deleteRetiredDimensions = Effect.fn("ModelStatRepo.deleteRetiredDimensions")(function* ( rows: ModelStatRow[], ) { @@ -165,6 +197,15 @@ export class ModelStatRepo extends Context.Service Effect.tryPromise({ - try: () => - db - .insert(providerStat) - .values(chunk) - .onDuplicateKeyUpdate({ - set: { - sessions: inserted("sessions"), - requests: inserted("requests"), - unique_users: inserted("unique_users"), - input_tokens: inserted("input_tokens"), - output_tokens: inserted("output_tokens"), - reasoning_tokens: inserted("reasoning_tokens"), - cache_read_tokens: inserted("cache_read_tokens"), - total_tokens: inserted("total_tokens"), - input_cost_microcents: inserted("input_cost_microcents"), - output_cost_microcents: inserted("output_cost_microcents"), - total_cost_microcents: inserted("total_cost_microcents"), - avg_duration_ms: inserted("avg_duration_ms"), - p50_duration_ms: inserted("p50_duration_ms"), - p95_duration_ms: inserted("p95_duration_ms"), - avg_ttfb_ms: inserted("avg_ttfb_ms"), - p50_ttfb_ms: inserted("p50_ttfb_ms"), - p95_ttfb_ms: inserted("p95_ttfb_ms"), - avg_output_tps: inserted("avg_output_tps"), - success_count: inserted("success_count"), - error_count: inserted("error_count"), - sample_count: inserted("sample_count"), - market_share_tokens: inserted("market_share_tokens"), - market_share_requests: inserted("market_share_requests"), - market_share_sessions: inserted("market_share_sessions"), - rank_by_tokens: inserted("rank_by_tokens"), - rank_by_requests: inserted("rank_by_requests"), - rank_by_sessions: inserted("rank_by_sessions"), - rank_by_cost: inserted("rank_by_cost"), - }, - }), + try: async () => { + try { + return await upsertProviderChunk(chunk, true) + } catch (cause) { + if (!isMissingUniqueUsersColumn(cause)) throw cause + return upsertProviderChunk(chunk, false) + } + }, catch: (cause) => DatabaseError.make({ cause }), }), { discard: true }, ) }) + function upsertProviderChunk(chunk: ProviderStatRow[], includeUniqueUsers: boolean) { + return db + .insert(providerStat) + .values(includeUniqueUsers ? chunk : omitUniqueUsers(chunk)) + .onDuplicateKeyUpdate({ + set: { + sessions: inserted("sessions"), + requests: inserted("requests"), + ...(includeUniqueUsers ? { unique_users: inserted("unique_users") } : {}), + input_tokens: inserted("input_tokens"), + output_tokens: inserted("output_tokens"), + reasoning_tokens: inserted("reasoning_tokens"), + cache_read_tokens: inserted("cache_read_tokens"), + total_tokens: inserted("total_tokens"), + input_cost_microcents: inserted("input_cost_microcents"), + output_cost_microcents: inserted("output_cost_microcents"), + total_cost_microcents: inserted("total_cost_microcents"), + avg_duration_ms: inserted("avg_duration_ms"), + p50_duration_ms: inserted("p50_duration_ms"), + p95_duration_ms: inserted("p95_duration_ms"), + avg_ttfb_ms: inserted("avg_ttfb_ms"), + p50_ttfb_ms: inserted("p50_ttfb_ms"), + p95_ttfb_ms: inserted("p95_ttfb_ms"), + avg_output_tps: inserted("avg_output_tps"), + success_count: inserted("success_count"), + error_count: inserted("error_count"), + sample_count: inserted("sample_count"), + market_share_tokens: inserted("market_share_tokens"), + market_share_requests: inserted("market_share_requests"), + market_share_sessions: inserted("market_share_sessions"), + rank_by_tokens: inserted("rank_by_tokens"), + rank_by_requests: inserted("rank_by_requests"), + rank_by_sessions: inserted("rank_by_sessions"), + rank_by_cost: inserted("rank_by_cost"), + }, + }) + } + const deleteRetiredDimensions = Effect.fn("ProviderStatRepo.deleteRetiredDimensions")(function* ( rows: ProviderStatRow[], ) { diff --git a/packages/stats/core/src/domain/stat.ts b/packages/stats/core/src/domain/stat.ts index 356f7403a89..553c91b9a50 100644 --- a/packages/stats/core/src/domain/stat.ts +++ b/packages/stats/core/src/domain/stat.ts @@ -147,6 +147,18 @@ export function combineRows(left: T, right: T): T { } } +export function isMissingUniqueUsersColumn(cause: unknown): boolean { + return errorText(cause).includes("Unknown column 'unique_users'") +} + +export function omitUniqueUsers(rows: T[]) { + return rows.map((row) => { + const result = { ...row } + delete result.unique_users + return result + }) +} + export function statPeriodKey(row: StatBaseRow) { return [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source].join("\u0000") } @@ -242,6 +254,15 @@ export function inserted(column: string) { return sql.raw(`values(\`${column}\`)`) } +function errorText(cause: unknown): string { + if (cause instanceof Error) return `${cause.message} ${errorText((cause as { cause?: unknown }).cause)}` + if (typeof cause === "object" && cause) + return Object.values(cause as Record) + .map(errorText) + .join(" ") + return String(cause) +} + export function weightedAverage( left: number | null | undefined, leftWeight = 0, From 4f1a9d7aef56163d8fe265f7f9cbe295cc1df95a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 20 Jun 2026 23:04:30 +0200 Subject: [PATCH 018/112] fix(core): honor configured agent step limits (#33142) --- packages/core/src/session/runner/index.ts | 11 +- packages/core/src/session/runner/llm.ts | 48 ++++---- .../src/session/runner/max-steps.ts} | 4 +- packages/core/test/session-runner.test.ts | 110 +++++++++--------- packages/opencode/src/session/prompt.ts | 4 +- 5 files changed, 85 insertions(+), 92 deletions(-) rename packages/{opencode/src/session/prompt/max-steps.txt => core/src/session/runner/max-steps.ts} (90%) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 67a110413e7..4060cc6b044 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -1,7 +1,7 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" -import { Context, Effect, Schema } from "effect" +import { Context, Effect } from "effect" import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" @@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index" import type { SessionContextEpoch } from "../context-epoch" import type { ToolOutputStore } from "../../tool-output-store" -export class StepLimitExceededError extends Schema.TaggedErrorClass()( - "SessionRunner.StepLimitExceededError", - { - sessionID: SessionSchema.ID, - limit: Schema.Int, - }, -) {} - export type RunError = | LLMError | SessionRunnerModel.Error | MessageDecodeError | ContextSnapshotDecodeError - | StepLimitExceededError | SystemContext.InitializationBlocked | SessionContextEpoch.AgentReplacementBlocked | ToolOutputStore.Error diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 02a1eb3fed2..233a4aa4d82 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -3,6 +3,7 @@ import { LLMClient, LLMError, LLMEvent, + Message, SystemPart, isContextOverflowFailure, type ProviderErrorEvent, @@ -29,10 +30,11 @@ import { SessionHistory } from "../history" import { SessionInput } from "../input" import { SessionSchema } from "../schema" import { SessionStore } from "../store" -import { type RunError, Service, StepLimitExceededError } from "./index" +import { type RunError, Service } from "./index" import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" +import { MAX_STEPS_PROMPT } from "./max-steps" /** * Runs one durable coding-agent Session until it settles. @@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message" * - [ ] Replace local ownership with durable multi-node ownership when clustered. * - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. * - [ ] Honor interruption and reject stale work after runtime attachment replacement. - * - [x] Bound model steps. + * - [x] Honor optional agent step limits. * - [ ] Bound provider retries and repeated identical tool calls. * * - Runtime context assembly @@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message" * Durable activity recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one - * provider turn. Registry definitions are advertised, local tool calls are settled durably, and a - * bounded explicit loop starts the next provider turn after local settlement. + * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an + * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. */ -// QUESTION: Did this exist previously, or did we add this limit? Does it make sense? -const MAX_STEPS = 25 - export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -175,6 +174,7 @@ export const layer = Layer.effect( const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + step: number, recoverOverflow?: typeof compaction.compactAfterOverflow, ) { const session = yield* getSession(sessionID) @@ -214,7 +214,8 @@ export const layer = Layer.effect( const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const toolMaterialization = yield* tools.materialize(agent.info?.permissions) + const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps + const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, @@ -222,8 +223,9 @@ export const layer = Layer.effect( system: [agent.info?.system, system.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), - messages: toLLMMessages(context, model), - tools: toolMaterialization.definitions, + messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], + tools: toolMaterialization?.definitions ?? [], + toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(rebuildPreparedTurn()) @@ -254,6 +256,10 @@ export const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return + if (!toolMaterialization) { + yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps")) + return + } needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) yield* Effect.uninterruptibleMask((restore) => @@ -340,31 +346,32 @@ export const layer = Layer.effect( type RunTurn = ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + step: number, ) => Effect.Effect - const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { - return yield* runTurnAttempt(sessionID, promotion).pipe( + const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { + return yield* runTurnAttempt(sessionID, promotion, step).pipe( Effect.catchDefect( Effect.fnUntraced(function* (defect) { if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion) + return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step) }), ), ) }) - const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { - return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe( + const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { + return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe( Effect.catchDefect( Effect.fnUntraced(function* (defect) { if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") - return yield* runAfterOverflowCompaction(sessionID, undefined) - return yield* runTurn(sessionID, defect.transition.promotion) + return yield* runAfterOverflowCompaction(sessionID, undefined, step) + return yield* runTurn(sessionID, defect.transition.promotion, step) }), ), ) @@ -382,14 +389,11 @@ export const layer = Layer.effect( let openActivity = input.force === true || hasSteer || hasQueue while (openActivity) { let needsContinuation = true - for (let step = 0; step < MAX_STEPS; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion) + for (let step = 1; needsContinuation; step++) { + needsContinuation = yield* runTurn(input.sessionID, promotion, step) promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") - if (!needsContinuation) break } - if (needsContinuation) - return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS }) openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") promotion = openActivity ? "queue" : undefined } diff --git a/packages/opencode/src/session/prompt/max-steps.txt b/packages/core/src/session/runner/max-steps.ts similarity index 90% rename from packages/opencode/src/session/prompt/max-steps.txt rename to packages/core/src/session/runner/max-steps.ts index 3aefa73779c..040584ab173 100644 --- a/packages/opencode/src/session/prompt/max-steps.txt +++ b/packages/core/src/session/runner/max-steps.ts @@ -1,4 +1,4 @@ -CRITICAL - MAXIMUM STEPS REACHED +export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. @@ -13,4 +13,4 @@ Response must include: - List of any remaining tasks that were not completed - Recommendations for what should be done next -Any attempt to use tools is a critical violation. Respond with text ONLY. \ No newline at end of file +Any attempt to use tools is a critical violation. Respond with text ONLY.` diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index af17de17594..c3089da0dba 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => { }) requests.length = 0 + executions.length = 0 responses = [ fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents, fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents, @@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("fails after the bounded number of local tool continuation steps", () => + it.effect("continues past 25 local tool steps when the agent has no step limit", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => { executions.length = 0 streamGate = undefined streamStarted = undefined - responses = Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]) - - const failure = yield* session.resume(sessionID).pipe(Effect.flip) - - expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 }) - expect(requests).toHaveLength(25) - expect(executions).toHaveLength(25) - }), - ) - - it.effect("does not restart a capped tool loop for a coalesced stale wake", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const coordinator = yield* SessionRunCoordinator.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false }) - - requests.length = 0 - responses = Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]) - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() - - const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) - yield* coordinator.wake(sessionID) - yield* Deferred.succeed(streamGate, undefined) - expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" }) - streamGate = undefined - streamStarted = undefined - yield* Effect.yieldNow - - expect(requests).toHaveLength(25) - }), - ) - - it.effect("accepts a terminal response on the final bounded provider turn", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) - - requests.length = 0 responses = [ - ...Array.from({ length: 24 }, (_, index) => [ + ...Array.from({ length: 25 }, (_, index) => [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }), + LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ]), @@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) - expect(requests).toHaveLength(25) + expect(requests).toHaveLength(26) + expect(executions).toHaveLength(25) + }), + ) + + it.effect("forces a text response on an agent's configured final step", () => + Effect.gen(function* () { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.update((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + + requests.length = 0 + executions.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[0]?.toolChoice).toBeUndefined() + expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[1]?.tools).toEqual([]) + expect(requests[1]?.messages.at(-1)).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + }) + expect(executions).toEqual(["done"]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Finish at the limit" }, + { type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] }, + { type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] }, + ]) }), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b616df6e598..299a0b6b173 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -16,7 +16,7 @@ import { SessionCompaction } from "./compaction" import { SystemPrompt } from "./system" import { Instruction } from "./instruction" import { Plugin } from "../plugin" -import MAX_STEPS from "../session/prompt/max-steps.txt" +import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps" import { ToolRegistry } from "@/tool/registry" import { MCP } from "../mcp" import { LSP } from "@/lsp/lsp" @@ -1322,7 +1322,7 @@ export const layer = Layer.effect( sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])], tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, From 0b7ec51d0aefe9799e3bbe4786fe7830df6214db Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 20 Jun 2026 21:05:57 +0000 Subject: [PATCH 019/112] chore: generate --- packages/opencode/src/session/prompt.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 299a0b6b173..dad796c998a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1322,7 +1322,10 @@ export const layer = Layer.effect( sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])], + messages: [ + ...modelMsgs, + ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : []), + ], tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, From 22cc758b1ace1f31d84695f6ec2b8508b4c60d54 Mon Sep 17 00:00:00 2001 From: imranshaiedi-byte Date: Sun, 21 Jun 2026 05:59:08 +0800 Subject: [PATCH 020/112] feat(opencode): expose High/Max thinking variants for GLM-5.2 (#32446) Co-authored-by: Aiden Cline --- packages/opencode/src/provider/transform.ts | 24 ++++- .../opencode/test/provider/transform.test.ts | 96 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f78cc53e756..e459380faae 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -666,6 +666,9 @@ export function variants(model: Provider.Model): Record id.includes(name) || model.api.id.toLowerCase().includes(name), + ) if ( model.api.id.toLowerCase().includes("minimax-m3") && ["@ai-sdk/anthropic", "@ai-sdk/openai-compatible"].includes(model.api.npm) @@ -677,13 +680,32 @@ export function variants(model: Provider.Model): Record { expect(result).toEqual({}) }) + test("glm-5.2 returns native effort variants for openai-compatible providers", () => { + const model = createMockModel({ + id: "zhipuai/glm-5.2", + providerID: "zhipuai", + api: { + id: "glm-5.2", + url: "https://open.bigmodel.cn/api/paas/v4", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + }) + + test("recognizes GLM-5.2 provider model IDs", () => { + for (const id of ["accounts/fireworks/models/glm-5p2", "zai-org-glm-5-2", "umans-glm-5.2"]) { + const model = createMockModel({ + id: `test/${id}`, + api: { + id, + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + } + }) + + test("recognizes GLM-5.2 from the API ID when the configured model ID is an alias", () => { + const model = createMockModel({ + id: "custom/my-glm", + api: { + id: "accounts/fireworks/models/glm-5p2", + url: "https://api.fireworks.ai/inference/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + }) + + test("glm-5.2 returns openrouter effort variants for openrouter", () => { + const model = createMockModel({ + id: "openrouter/z-ai/glm-5.2", + providerID: "openrouter", + api: { + id: "z-ai/glm-5.2", + url: "https://openrouter.ai/api/v1", + npm: "@openrouter/ai-sdk-provider", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoning: { effort: "high" } }, + xhigh: { reasoning: { effort: "xhigh" } }, + }) + }) + + test("glm-5.2 returns effort variants for anthropic-compatible providers", () => { + const model = createMockModel({ + id: "zai-coding-plan/glm-5.2", + providerID: "zai-coding-plan", + api: { + id: "glm-5.2", + url: "https://api.z.ai/api/anthropic", + npm: "@ai-sdk/anthropic", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { effort: "high" }, + max: { effort: "max" }, + }) + }) + + test("glm-5.2 falls back to provider defaults for other packages", () => { + const model = createMockModel({ + id: "test/glm-5.2", + api: { + id: "glm-5.2", + url: "https://api.test.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + medium: { reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" } }, + high: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }) + }) + test("mistral models with reasoning support return variants", () => { const model = createMockModel({ id: "mistral/mistral-small-latest", From d99f86b28db10c9b9460cb0bf7f984c1bd5cdb7f Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 21 Jun 2026 00:58:27 +0200 Subject: [PATCH 021/112] fix(tui): separate subagent tool rows (#33158) --- packages/tui/src/routes/session/index.tsx | 10 +++++++++- .../inline-tool-wrap-snapshot.test.tsx.snap | 5 ++++- .../test/cli/tui/inline-tool-wrap-snapshot.test.tsx | 8 ++++---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f36ddd9daba..e3758374c87 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1838,6 +1838,7 @@ function InlineTool(props: { pending: string failure?: string spinner?: boolean + separate?: boolean children: JSX.Element part: ToolPart onClick?: () => void @@ -1890,6 +1891,7 @@ function InlineTool(props: { pending={props.pending} failure={props.failure} spinner={props.spinner} + separate={props.separate} onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -1919,6 +1921,7 @@ export function InlineToolRow(props: { pending: string failure?: string spinner?: boolean + separate?: boolean children: JSX.Element onMouseOver?: () => void onMouseOut?: () => void @@ -1931,8 +1934,12 @@ export function InlineToolRow(props: { onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp} ref={(el: BoxRenderable) => { + if (props.separate) alwaysSeparate.add(el) setPreLayoutSiblingMargin(el, (previous) => { - return previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous)) ? 1 : 0 + return props.separate || + (previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous))) + ? 1 + : 0 }) }} > @@ -2281,6 +2288,7 @@ function Task(props: ToolProps) { return ( Grep "Task" (2 matches) - + Explore Task — Inspect active task spacing - + {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"} @@ -134,7 +134,7 @@ function LoadedReadBeforeTaskFixture() { ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx - + {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"} @@ -312,7 +312,7 @@ describe("TUI inline tool wrapping", () => { expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot() }) - test("does not treat task rows differently from other inline rows", async () => { + test("separates a task row from a preceding inline detail", async () => { expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() }) From 4f1ae1604ec2b57f50b5e933fd3929f1dbe4bd00 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 21 Jun 2026 01:57:29 +0200 Subject: [PATCH 022/112] chore: upgrade Effect to beta 83 (#32340) --- bun.lock | 32 ++++++++++++------- package.json | 8 ++--- packages/core/src/account.ts | 8 ++--- packages/core/src/aisdk.ts | 2 +- .../core/src/control-plane/move-session.ts | 2 +- packages/core/src/fs-util.ts | 2 +- packages/core/src/git.ts | 4 +-- packages/core/src/integration.ts | 2 +- packages/core/src/npm.ts | 2 +- packages/core/src/process.ts | 2 +- packages/core/src/ripgrep.ts | 2 +- packages/core/src/tool-output-store.ts | 2 +- packages/core/src/util/effect-flock.ts | 2 +- packages/core/test/session-runner.test.ts | 4 +-- .../effect-drizzle-sqlite/examples/basic.ts | 2 +- packages/http-recorder/package.json | 6 ++-- packages/llm/src/schema/errors.ts | 2 +- packages/llm/src/schema/events.ts | 2 +- packages/opencode/src/account/schema.ts | 8 ++--- packages/opencode/src/auth/index.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/src/lsp/client.ts | 2 +- packages/opencode/src/provider/provider.ts | 4 +-- packages/stats/core/src/athena.ts | 2 +- packages/stats/core/src/database.ts | 4 +-- packages/stats/server/src/ingest.ts | 2 +- 26 files changed, 60 insertions(+), 52 deletions(-) diff --git a/bun.lock b/bun.lock index 4e4aa7a707d..a0b3d9b31bb 100644 --- a/bun.lock +++ b/bun.lock @@ -460,8 +460,8 @@ "name": "@opencode-ai/http-recorder", "version": "1.17.8", "dependencies": { - "@effect/platform-node": "4.0.0-beta.74", - "@effect/platform-node-shared": "4.0.0-beta.74", + "@effect/platform-node": "4.0.0-beta.83", + "@effect/platform-node-shared": "4.0.0-beta.83", }, "devDependencies": { "@tsconfig/node22": "catalog:", @@ -472,7 +472,7 @@ "typescript": "catalog:", }, "peerDependencies": { - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", }, }, "packages/llm": { @@ -935,9 +935,9 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@effect/opentelemetry": "4.0.0-beta.74", - "@effect/platform-node": "4.0.0-beta.74", - "@effect/sql-sqlite-bun": "4.0.0-beta.74", + "@effect/opentelemetry": "4.0.0-beta.83", + "@effect/platform-node": "4.0.0-beta.83", + "@effect/sql-sqlite-bun": "4.0.0-beta.83", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", @@ -973,7 +973,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -1328,13 +1328,13 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -3366,7 +3366,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -5898,6 +5898,10 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + + "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6700,6 +6704,10 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], diff --git a/package.json b/package.json index c0dc905aae4..49507128d60 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,9 @@ "packages/slack" ], "catalog": { - "@effect/opentelemetry": "4.0.0-beta.74", - "@effect/platform-node": "4.0.0-beta.74", - "@effect/sql-sqlite-bun": "4.0.0-beta.74", + "@effect/opentelemetry": "4.0.0-beta.83", + "@effect/platform-node": "4.0.0-beta.83", + "@effect/sql-sqlite-bun": "4.0.0-beta.83", "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.13", "@types/cross-spawn": "6.0.6", @@ -61,7 +61,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts index 4de8176e4bc..d364d6f3448 100644 --- a/packages/core/src/account.ts +++ b/packages/core/src/account.ts @@ -35,19 +35,19 @@ export class Org extends Schema.Class("Org")({ export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { method: Schema.String, url: Schema.String, description: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { return new AccountTransportError({ @@ -94,7 +94,7 @@ export class PollExpired extends Schema.TaggedClass()("PollExpired" export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} export class PollError extends Schema.TaggedClass()("PollError", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 9965ff930dd..769941fd276 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -109,7 +109,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) { export class InitError extends Schema.TaggedErrorClass()("AISDK.InitError", { providerID: ProviderV2.ID, - cause: Schema.Defect, + cause: Schema.Defect(), }) {} function initError(providerID: ProviderV2.ID) { diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index 0239eecada2..fa2a3cb5493 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -48,7 +48,7 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass()("FileSystemError", { method: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export type Error = PlatformError | FileSystemError diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 0041c3353f4..b7ef916330e 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -32,14 +32,14 @@ export class WorktreeError extends Schema.TaggedErrorClass()("Git message: Schema.String, directory: Schema.optional(AbsolutePath), forceRequired: Schema.optional(Schema.Boolean), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class PatchError extends Schema.TaggedErrorClass()("Git.PatchError", { operation: Schema.Literals(["capture", "apply", "reset"]), directory: AbsolutePath, message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export interface Interface { diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index ca626111a01..90995b1987c 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -154,7 +154,7 @@ export class CodeRequiredError extends Schema.TaggedErrorClass()("Integration.Authorization", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export type Error = CodeRequiredError | AuthorizationError diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index f3398e83911..48ad74c1807 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -15,7 +15,7 @@ import { NpmConfig } from "./npm-config" export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { add: Schema.Array(Schema.String).pipe(Schema.optional), dir: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export interface EntryPoint { diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index 44418d74c1b..8367ad68e15 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -9,7 +9,7 @@ export class AppProcessError extends Schema.TaggedErrorClass()( command: Schema.String, exitCode: Schema.optional(Schema.Number), stderr: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export interface RunOptions { diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 5a8a94f337d..b68078b3fef 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -42,7 +42,7 @@ type RawMatchData = (typeof RawMatch.Type)["data"] export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class InvalidPatternError extends Schema.TaggedErrorClass()("Ripgrep.InvalidPatternError", { diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 2d15ee8d0dc..685cf5c582b 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -28,7 +28,7 @@ export interface BoundResult { export class StorageError extends Schema.TaggedErrorClass()("ToolOutputStore.StorageError", { operation: Schema.Literals(["encode", "write"]), - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export type Error = StorageError diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index 2ba5ef0d759..fa864e92525 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -24,7 +24,7 @@ export namespace EffectFlock { class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", { detail: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { override get message() { return this.detail diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c3089da0dba..6ff969fb62a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: { directory: AbsolutePath.make("/moved") }, + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), }) expect( yield* db @@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => { .publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: { directory: AbsolutePath.make("/moved") }, + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), }) .pipe(Effect.asVoid) }) diff --git a/packages/effect-drizzle-sqlite/examples/basic.ts b/packages/effect-drizzle-sqlite/examples/basic.ts index 675aabcb857..80397cd6e6c 100644 --- a/packages/effect-drizzle-sqlite/examples/basic.ts +++ b/packages/effect-drizzle-sqlite/examples/basic.ts @@ -25,7 +25,7 @@ class Database extends Context.Service()("@opencode/exa class UserStoreError extends Schema.TaggedErrorClass()("UserStoreError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} const mapStoreError = (message: string) => (cause: unknown) => new UserStoreError({ message, cause }) diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 29bda69edf9..c9fd3434ca0 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -51,10 +51,10 @@ "typescript": "catalog:" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.74", - "@effect/platform-node-shared": "4.0.0-beta.74" + "@effect/platform-node": "4.0.0-beta.83", + "@effect/platform-node-shared": "4.0.0-beta.83" }, "peerDependencies": { - "effect": "4.0.0-beta.74" + "effect": "4.0.0-beta.83" } } diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 35546ca30b4..072e4e83892 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -202,6 +202,6 @@ export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", { */ export class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { message: Schema.String, - error: Schema.optional(Schema.Defect), + error: Schema.optional(Schema.Defect()), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index a685f07d5e1..3e46013521e 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -175,7 +175,7 @@ export const ToolError = Schema.Struct({ id: ToolCallID, name: Schema.String, message: Schema.String, - error: Schema.optional(Schema.Defect), + error: Schema.optional(Schema.Defect()), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolError" }) export type ToolError = Schema.Schema.Type diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 222296ff1bc..8c008435ab5 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -33,19 +33,19 @@ export class Org extends Schema.Class("Org")({ export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { method: Schema.String, url: Schema.String, description: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { return new AccountTransportError({ @@ -92,7 +92,7 @@ export class PollExpired extends Schema.TaggedClass()("PollExpired" export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} export class PollError extends Schema.TaggedClass()("PollError", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 5c18bc3caad..20f93798242 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -37,7 +37,7 @@ export type Info = Schema.Schema.Type export class AuthError extends Schema.TaggedErrorClass()("AuthError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export interface Interface { diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 76aab6ef61a..0fdd6f0c7dc 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -131,7 +131,7 @@ export class SyncTimeoutError extends Schema.TaggedErrorClass( export class SyncAbortedError extends Schema.TaggedErrorClass()("WorkspaceSyncAbortedError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} type CreateError = Auth.AuthError diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 0949ec1be08..08d8a53d9be 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -28,7 +28,7 @@ export type Diagnostic = VSCodeDiagnostic export class InitializeError extends Schema.TaggedErrorClass()("LSPInitializeError", { serverID: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} type DocumentDiagnosticReport = { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 63ad8d0d7f6..d7c85f2ee6a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1076,7 +1076,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass()("ProviderInitError", { providerID: ProviderV2.ID, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { static isInstance(input: unknown): input is InitError { return input instanceof InitError diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index a2be44ebb76..54037002f57 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -17,7 +17,7 @@ export type AthenaData = Record export class AthenaQueryError extends Schema.TaggedErrorClass()("AthenaQueryError", { message: Schema.String, queryExecutionId: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass()( diff --git a/packages/stats/core/src/database.ts b/packages/stats/core/src/database.ts index d265f82bf1e..9edb717bc71 100644 --- a/packages/stats/core/src/database.ts +++ b/packages/stats/core/src/database.ts @@ -45,14 +45,14 @@ export class DrizzleClient extends Context.Service()("@o } export class DatabaseError extends Schema.TaggedErrorClass()("DatabaseError", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export const catchDbError = Effect.mapError((cause) => DatabaseError.make({ cause })) export class MigrationError extends Schema.TaggedErrorClass()("MigrationError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export const migrate = Effect.fn("Database.migrate")(function* () { diff --git a/packages/stats/server/src/ingest.ts b/packages/stats/server/src/ingest.ts index 62972e30fab..763742d9c99 100644 --- a/packages/stats/server/src/ingest.ts +++ b/packages/stats/server/src/ingest.ts @@ -15,7 +15,7 @@ type FirehoseRecord = { Data: Uint8Array } export class IngestError extends Schema.TaggedErrorClass()("IngestError", { message: Schema.String, failed: Schema.Number, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export declare namespace Ingest { From e84d94d99804c647c418c274ac9120ef27a5b46d Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 21 Jun 2026 00:03:08 +0000 Subject: [PATCH 023/112] sync release versions for v1.17.9 --- bun.lock | 52 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 28 files changed, 53 insertions(+), 53 deletions(-) diff --git a/bun.lock b/bun.lock index a0b3d9b31bb..e38e531d994 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -86,7 +86,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.8", + "version": "1.17.9", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -111,7 +111,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -147,7 +147,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -174,7 +174,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -196,7 +196,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -220,7 +220,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -240,7 +240,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.8", + "version": "1.17.9", "bin": { "opencode": "./bin/opencode", }, @@ -331,7 +331,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -385,7 +385,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -399,7 +399,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "effect": "catalog:", }, @@ -411,7 +411,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -442,7 +442,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -458,7 +458,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -477,7 +477,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -495,7 +495,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.8", + "version": "1.17.9", "bin": { "opencode": "./bin/opencode", }, @@ -623,7 +623,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -661,7 +661,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "cross-spawn": "catalog:", }, @@ -676,7 +676,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -690,7 +690,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -703,7 +703,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -736,7 +736,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -755,7 +755,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -795,7 +795,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -822,7 +822,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -871,7 +871,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index b572e7308b7..e4906b9ea4b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.8", + "version": "1.17.9", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d2aba06697..96283c8269c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.17.8", + "version": "1.17.9", "type": "module", "license": "MIT", "bin": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index e7840b4782c..e706bd6b062 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.8", + "version": "1.17.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index b5e95fa946c..fa401b65c22 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.17.8", + "version": "1.17.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index c215d9da491..7db84f475f0 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.17.8", + "version": "1.17.9", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 6adb5489ddc..deb27b5e2c4 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.17.8", + "version": "1.17.9", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 2b6d53eac86..e7ca9810a3c 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.17.8", + "version": "1.17.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 26449b5165b..7e12e5bac1f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.8", + "version": "1.17.9", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 7933b3982c9..d3bd09bc0f5 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.17.8", + "version": "1.17.9", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 033ccb4f0de..8fe9c58f5e2 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.8", + "version": "1.17.9", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 52860ade59b..3f8f18a9aaf 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.8", + "version": "1.17.9", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index cf775b9015c..69534b07f73 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.17.8", + "version": "1.17.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 5a9259f61b7..c4d35031cb1 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.17.8", + "version": "1.17.9", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index c9fd3434ca0..7c70a18f209 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.8", + "version": "1.17.9", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 205a27c3225..e1a691c0eed 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.8", + "version": "1.17.9", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fe6e8a8ff1b..139217e348a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.8", + "version": "1.17.9", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b3c4f40137c..d046e4e9f6d 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.17.8", + "version": "1.17.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a0dc88f4a3b..37b6889078b 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.17.8", + "version": "1.17.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 9bc7f446777..18cd2ab1f2a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.17.8", + "version": "1.17.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index d04cac52434..c4ba07b2391 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.8", + "version": "1.17.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 8317c5bc5e9..d7c6660d702 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.17.8", + "version": "1.17.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 2dcfd54655b..c54cb7434cf 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.17.8", + "version": "1.17.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index a53b24236b4..c10273a3a0d 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.17.8", + "version": "1.17.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index be0ddbfe416..aa6b6c70f04 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.17.8", + "version": "1.17.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index a2485f475a6..489396ad499 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.17.8", + "version": "1.17.9", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 756726d1500..3d77162aa1e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.17.8", + "version": "1.17.9", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 37c940a312c..ead6f458879 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.17.8", + "version": "1.17.9", "publisher": "sst-dev", "repository": { "type": "git", From 5606d2bab9744e5ec7992fffe424569fadf37ae7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 21 Jun 2026 00:19:31 +0000 Subject: [PATCH 024/112] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index b7231c92abc..86502338019 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=", - "aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=", - "aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=", - "x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY=" + "x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=", + "aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=", + "aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=", + "x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y=" } } From d59619fffd517357c582b4b376feb589bdf945ab Mon Sep 17 00:00:00 2001 From: James Long Date: Sun, 21 Jun 2026 03:43:03 +0200 Subject: [PATCH 025/112] test(opencode): simplify git layer wiring (#33156) --- packages/opencode/test/git/git.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index e80b8fa9065..c786513c636 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -3,12 +3,13 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Git } from "../../src/git" import { tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const it = testEffect(Git.defaultLayer) +const it = testEffect(LayerNode.buildLayer(Git.node)) const scopedTmpdir = (options?: Parameters[0]) => Effect.acquireRelease( From 468f425e76b324890a6ca05d3bc0fe59b0058cb3 Mon Sep 17 00:00:00 2001 From: James Long Date: Sun, 21 Jun 2026 03:44:02 +0200 Subject: [PATCH 026/112] test(opencode): simplify session retry layer wiring (#33155) --- packages/opencode/test/session/retry.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index f5edf1af24b..e53a6c1f18e 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -3,7 +3,8 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Layer, Schedule, Schema } from "effect" +import { Effect, Schedule, Schema } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -15,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" const providerID = ProviderV2.ID.make("test") const retryProvider = "test" -const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node]))) function apiError(headers?: Record): SessionV1.APIError { return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( From d3bbfff826c58708bb55ef11737943436305da7b Mon Sep 17 00:00:00 2001 From: James Long Date: Sun, 21 Jun 2026 03:44:37 +0200 Subject: [PATCH 027/112] test(opencode): simplify message pagination layer wiring (#33157) --- packages/opencode/src/session/message-v2.ts | 3 +++ packages/opencode/test/session/messages-pagination.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1590e089037..813fe49f325 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -22,6 +22,7 @@ import { import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { NotFoundError } from "@/storage/storage" import { and } from "drizzle-orm" import { desc } from "drizzle-orm" @@ -38,6 +39,8 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { Effect, Schema } from "effect" +export const node = LayerNode.group([Database.node]) + /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { code: "ZlibError" diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index c0b65b5e57f..4706235f350 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Database } from "@opencode-ai/core/database/database" -import { Effect, Layer, Option } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Effect, Option } from "effect" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -11,7 +12,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node]))) const withSession = ( fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, From f12ac6f234ebe31982ee78f3359e8170cb09ffc9 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 21 Jun 2026 04:24:59 +0200 Subject: [PATCH 028/112] fix(tui): reduce noisy MCP autocomplete matches (#33176) --- packages/tui/src/component/prompt/autocomplete.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 6fae556fc1f..f2916173da3 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -359,9 +359,8 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 for (const res of Object.values(sync.data.mcp_resource)) { - const text = `${res.name} (${res.uri})` options.push({ - display: Locale.truncateMiddle(text, width), + display: Locale.truncateMiddle(res.name, width), // Match the name only; matching the URI caused unrelated fuzzy hits. value: res.name, description: res.description, @@ -497,6 +496,7 @@ export function Autocomplete(props: { ...(store.visible === "/" ? ["description" as const] : []), (obj) => obj.aliases?.join(" ") ?? "", ], + threshold: store.visible === "@" ? 0.5 : 0, limit: 10, scoreFn: (objResults) => { const displayResult = objResults[0] From 233d065dd5edbfaabf832e68a6f0a5b3d841ee0b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:30:25 -0500 Subject: [PATCH 029/112] feat(stats): show model users metric --- .github/workflows/deploy.yml | 14 ++++++++++++++ packages/stats/app/src/routes/[lab]/[model].tsx | 1 + packages/stats/core/src/domain/home.ts | 2 ++ 3 files changed, 17 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 18e6cf7acb4..2c326bb6f4a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,3 +46,17 @@ jobs: SENTRY_RELEASE: web@${{ github.sha }} VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_RELEASE: web@${{ github.sha }} + + - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:migrate + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} + PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} + STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} + HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: web@${{ github.sha }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 6869764f333..c9b7c33ee98 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -340,6 +340,7 @@ function ModelOverview(props: { data: StatsModelData | null }) { {(data) => (
+ 0 ? Math.round(current.totalTokens / current.sessions) : 0, From 6f0e934573b3913c3cd5d015f096fc82da8fa22b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:38:47 -0500 Subject: [PATCH 030/112] fix(stats): make unique users migration idempotent --- .github/workflows/deploy.yml | 2 +- packages/stats/core/package.json | 1 + packages/stats/core/src/athena.ts | 2 +- packages/stats/core/src/database.ts | 4 +-- .../stats/core/src/ensure-unique-users.ts | 26 +++++++++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 packages/stats/core/src/ensure-unique-users.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2c326bb6f4a..22342a0ee4a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,7 +47,7 @@ jobs: VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_RELEASE: web@${{ github.sha }} - - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:migrate + - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index c54cb7434cf..38379bc2e9f 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -17,6 +17,7 @@ }, "scripts": { "db:generate": "drizzle-kit generate --config=drizzle.config.ts", + "db:ensure-unique-users": "bun src/ensure-unique-users.ts", "db:migrate": "bun src/migrate.ts", "db:push": "drizzle-kit push --config=drizzle.config.ts", "db:studio": "drizzle-kit studio --config=drizzle.config.ts", diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index 54037002f57..a2be44ebb76 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -17,7 +17,7 @@ export type AthenaData = Record export class AthenaQueryError extends Schema.TaggedErrorClass()("AthenaQueryError", { message: Schema.String, queryExecutionId: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect()), + cause: Schema.optional(Schema.Defect), }) {} export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass()( diff --git a/packages/stats/core/src/database.ts b/packages/stats/core/src/database.ts index 9edb717bc71..d265f82bf1e 100644 --- a/packages/stats/core/src/database.ts +++ b/packages/stats/core/src/database.ts @@ -45,14 +45,14 @@ export class DrizzleClient extends Context.Service()("@o } export class DatabaseError extends Schema.TaggedErrorClass()("DatabaseError", { - cause: Schema.Defect(), + cause: Schema.Defect, }) {} export const catchDbError = Effect.mapError((cause) => DatabaseError.make({ cause })) export class MigrationError extends Schema.TaggedErrorClass()("MigrationError", { message: Schema.String, - cause: Schema.optional(Schema.Defect()), + cause: Schema.optional(Schema.Defect), }) {} export const migrate = Effect.fn("Database.migrate")(function* () { diff --git a/packages/stats/core/src/ensure-unique-users.ts b/packages/stats/core/src/ensure-unique-users.ts new file mode 100644 index 00000000000..af610cfb743 --- /dev/null +++ b/packages/stats/core/src/ensure-unique-users.ts @@ -0,0 +1,26 @@ +import { Client } from "@planetscale/database" +import { Resource } from "sst/resource" + +const tables = ["geo_stat", "model_stat", "provider_stat"] as const + +const client = new Client({ url: Resource.StatsDatabase.url }) + +await tables.reduce( + (promise, table) => promise.then(() => ensureUniqueUsersColumn(table)), + Promise.resolve(), +) + +async function ensureUniqueUsersColumn(table: (typeof tables)[number]) { + const result = await client.execute<{ column_name: string }>( + "SELECT column_name FROM information_schema.columns WHERE table_schema = database() AND table_name = ? AND column_name = 'unique_users'", + [table], + ) + + if (result.rows.length > 0) { + console.log(`unique_users column already exists on ${table}`) + return + } + + await client.execute(`ALTER TABLE \`${table}\` ADD \`unique_users\` bigint NOT NULL DEFAULT 0`) + console.log(`added unique_users column to ${table}`) +} From d4d841bafdf3255abbf89339a625c160e4f60bc9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 21 Jun 2026 09:40:15 +0000 Subject: [PATCH 031/112] chore: generate --- packages/stats/core/src/ensure-unique-users.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/stats/core/src/ensure-unique-users.ts b/packages/stats/core/src/ensure-unique-users.ts index af610cfb743..d3a7cc6e6ac 100644 --- a/packages/stats/core/src/ensure-unique-users.ts +++ b/packages/stats/core/src/ensure-unique-users.ts @@ -5,10 +5,7 @@ const tables = ["geo_stat", "model_stat", "provider_stat"] as const const client = new Client({ url: Resource.StatsDatabase.url }) -await tables.reduce( - (promise, table) => promise.then(() => ensureUniqueUsersColumn(table)), - Promise.resolve(), -) +await tables.reduce((promise, table) => promise.then(() => ensureUniqueUsersColumn(table)), Promise.resolve()) async function ensureUniqueUsersColumn(table: (typeof tables)[number]) { const result = await client.execute<{ column_name: string }>( From ffcb7542e1a6832e183b7a0d633b5514a70f5f5d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:00:54 -0500 Subject: [PATCH 032/112] fix(stats): run production migration safely --- .github/workflows/deploy.yml | 67 ++++++++++++++++++- packages/stats/core/package.json | 1 + .../stats/core/src/ensure-unique-users.ts | 55 +++++++++++++-- 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 22342a0ee4a..e000771c47f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,7 +47,8 @@ jobs: VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_RELEASE: web@${{ github.sha }} - - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users + - if: github.ref_name != 'production' + run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} @@ -60,3 +61,67 @@ jobs: SENTRY_RELEASE: web@${{ github.sha }} VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_RELEASE: web@${{ github.sha }} + + - if: github.ref_name == 'production' + uses: planetscale/setup-pscale-action@v1 + + - if: github.ref_name == 'production' + run: | + set -euo pipefail + + database="opencode-stats" + organization="anomalyco" + branch="unique-users-${GITHUB_SHA::12}" + password_id="" + + cleanup() { + if [ -n "$password_id" ]; then + pscale password delete "$database" "$branch" "$password_id" --org "$organization" --force >/dev/null 2>&1 || true + fi + pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true + } + + trap cleanup EXIT + + if bun sst shell --stage=production -- bun run --cwd packages/stats/core db:check-unique-users; then + echo "unique_users columns already exist in production" + exit 0 + fi + + pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true + pscale branch create "$database" "$branch" --org "$organization" --from production --wait + + response="$(pscale password create "$database" "$branch" "unique-users-${GITHUB_RUN_ID}" --org "$organization" --format json)" + password_id="$(echo "$response" | jq -r '.id')" + + export PLANETSCALE_HOST="$(echo "$response" | jq -r '.access_host_url')" + export PLANETSCALE_USERNAME="$(echo "$response" | jq -r '.username')" + export PLANETSCALE_PASSWORD="$(echo "$response" | jq -r '.plain_text')" + export PLANETSCALE_DATABASE="$database" + + echo "::add-mask::$PLANETSCALE_PASSWORD" + bun run --cwd packages/stats/core db:ensure-unique-users + + deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json)" + deploy_number="$(echo "$deploy_response" | jq -r '.number')" + + if [ -z "$deploy_number" ] || [ "$deploy_number" = "null" ]; then + echo "Could not read deploy request number" + exit 1 + fi + + pscale deploy-request review "$database" "$deploy_number" --org "$organization" --approve || true + pscale deploy-request deploy "$database" "$deploy_number" --org "$organization" + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} + PLANETSCALE_SERVICE_TOKEN_ID: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} + PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} + STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} + HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: web@${{ github.sha }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 38379bc2e9f..76c022a4946 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -17,6 +17,7 @@ }, "scripts": { "db:generate": "drizzle-kit generate --config=drizzle.config.ts", + "db:check-unique-users": "bun src/ensure-unique-users.ts --check", "db:ensure-unique-users": "bun src/ensure-unique-users.ts", "db:migrate": "bun src/migrate.ts", "db:push": "drizzle-kit push --config=drizzle.config.ts", diff --git a/packages/stats/core/src/ensure-unique-users.ts b/packages/stats/core/src/ensure-unique-users.ts index d3a7cc6e6ac..cd355083af7 100644 --- a/packages/stats/core/src/ensure-unique-users.ts +++ b/packages/stats/core/src/ensure-unique-users.ts @@ -2,22 +2,63 @@ import { Client } from "@planetscale/database" import { Resource } from "sst/resource" const tables = ["geo_stat", "model_stat", "provider_stat"] as const +const checkOnly = process.argv.includes("--check") -const client = new Client({ url: Resource.StatsDatabase.url }) +const client = new Client({ url: databaseUrl() }) -await tables.reduce((promise, table) => promise.then(() => ensureUniqueUsersColumn(table)), Promise.resolve()) +const missing = await tables.reduce>( + async (promise, table) => { + const result = await promise + if (await hasUniqueUsersColumn(table)) { + console.log(`unique_users column already exists on ${table}`) + return result + } + return [...result, table] + }, + Promise.resolve([]), +) -async function ensureUniqueUsersColumn(table: (typeof tables)[number]) { +if (missing.length === 0) { + console.log("unique_users columns complete") + process.exit(0) +} + +if (checkOnly) { + console.log(`unique_users columns missing on ${missing.join(", ")}`) + process.exit(1) +} + +await missing.reduce( + (promise, table) => promise.then(() => addUniqueUsersColumn(table)), + Promise.resolve(), +) + +function databaseUrl() { + if ( + process.env.PLANETSCALE_HOST && + process.env.PLANETSCALE_USERNAME && + process.env.PLANETSCALE_PASSWORD && + process.env.PLANETSCALE_DATABASE + ) + return `mysql://${encodeURIComponent(process.env.PLANETSCALE_USERNAME)}:${encodeURIComponent( + process.env.PLANETSCALE_PASSWORD, + )}@${process.env.PLANETSCALE_HOST}/${process.env.PLANETSCALE_DATABASE}?ssl=${encodeURIComponent( + JSON.stringify({ rejectUnauthorized: true }), + )}` + + return process.env.DATABASE_URL ?? Resource.StatsDatabase.url +} + +async function hasUniqueUsersColumn(table: (typeof tables)[number]) { const result = await client.execute<{ column_name: string }>( "SELECT column_name FROM information_schema.columns WHERE table_schema = database() AND table_name = ? AND column_name = 'unique_users'", [table], ) - if (result.rows.length > 0) { - console.log(`unique_users column already exists on ${table}`) - return - } + return result.rows.length > 0 +} +async function addUniqueUsersColumn(table: (typeof tables)[number]) { await client.execute(`ALTER TABLE \`${table}\` ADD \`unique_users\` bigint NOT NULL DEFAULT 0`) console.log(`added unique_users column to ${table}`) } From 24ea4dd0087c16d2f45f7404ec86b2ddb4a16077 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 21 Jun 2026 10:03:16 +0000 Subject: [PATCH 033/112] chore: generate --- .../stats/core/src/ensure-unique-users.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/stats/core/src/ensure-unique-users.ts b/packages/stats/core/src/ensure-unique-users.ts index cd355083af7..0786476281e 100644 --- a/packages/stats/core/src/ensure-unique-users.ts +++ b/packages/stats/core/src/ensure-unique-users.ts @@ -6,17 +6,14 @@ const checkOnly = process.argv.includes("--check") const client = new Client({ url: databaseUrl() }) -const missing = await tables.reduce>( - async (promise, table) => { - const result = await promise - if (await hasUniqueUsersColumn(table)) { - console.log(`unique_users column already exists on ${table}`) - return result - } - return [...result, table] - }, - Promise.resolve([]), -) +const missing = await tables.reduce>(async (promise, table) => { + const result = await promise + if (await hasUniqueUsersColumn(table)) { + console.log(`unique_users column already exists on ${table}`) + return result + } + return [...result, table] +}, Promise.resolve([])) if (missing.length === 0) { console.log("unique_users columns complete") @@ -28,10 +25,7 @@ if (checkOnly) { process.exit(1) } -await missing.reduce( - (promise, table) => promise.then(() => addUniqueUsersColumn(table)), - Promise.resolve(), -) +await missing.reduce((promise, table) => promise.then(() => addUniqueUsersColumn(table)), Promise.resolve()) function databaseUrl() { if ( From a97c6de9db785576792495eed0e7dd268533e2a7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:16:53 -0500 Subject: [PATCH 034/112] fix(stats): support planetscale cli variants --- .github/workflows/deploy.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e000771c47f..0ceaee3d41b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -102,7 +102,14 @@ jobs: echo "::add-mask::$PLANETSCALE_PASSWORD" bun run --cwd packages/stats/core db:ensure-unique-users - deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json)" + if deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --into production --format json 2>/tmp/deploy-request-error)"; then + : + elif deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json 2>>/tmp/deploy-request-error)"; then + : + else + cat /tmp/deploy-request-error + exit 1 + fi deploy_number="$(echo "$deploy_response" | jq -r '.number')" if [ -z "$deploy_number" ] || [ "$deploy_number" = "null" ]; then From bd8ce5e6a9157d74475e2657825034510105add4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:41:10 -0500 Subject: [PATCH 035/112] chore(stats): remove deploy migrations --- .github/workflows/deploy.yml | 86 ------------------------------------ 1 file changed, 86 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0ceaee3d41b..18e6cf7acb4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,89 +46,3 @@ jobs: SENTRY_RELEASE: web@${{ github.sha }} VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_RELEASE: web@${{ github.sha }} - - - if: github.ref_name != 'production' - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} - PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} - STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} - HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} - SENTRY_RELEASE: web@${{ github.sha }} - VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} - VITE_SENTRY_RELEASE: web@${{ github.sha }} - - - if: github.ref_name == 'production' - uses: planetscale/setup-pscale-action@v1 - - - if: github.ref_name == 'production' - run: | - set -euo pipefail - - database="opencode-stats" - organization="anomalyco" - branch="unique-users-${GITHUB_SHA::12}" - password_id="" - - cleanup() { - if [ -n "$password_id" ]; then - pscale password delete "$database" "$branch" "$password_id" --org "$organization" --force >/dev/null 2>&1 || true - fi - pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true - } - - trap cleanup EXIT - - if bun sst shell --stage=production -- bun run --cwd packages/stats/core db:check-unique-users; then - echo "unique_users columns already exist in production" - exit 0 - fi - - pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true - pscale branch create "$database" "$branch" --org "$organization" --from production --wait - - response="$(pscale password create "$database" "$branch" "unique-users-${GITHUB_RUN_ID}" --org "$organization" --format json)" - password_id="$(echo "$response" | jq -r '.id')" - - export PLANETSCALE_HOST="$(echo "$response" | jq -r '.access_host_url')" - export PLANETSCALE_USERNAME="$(echo "$response" | jq -r '.username')" - export PLANETSCALE_PASSWORD="$(echo "$response" | jq -r '.plain_text')" - export PLANETSCALE_DATABASE="$database" - - echo "::add-mask::$PLANETSCALE_PASSWORD" - bun run --cwd packages/stats/core db:ensure-unique-users - - if deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --into production --format json 2>/tmp/deploy-request-error)"; then - : - elif deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json 2>>/tmp/deploy-request-error)"; then - : - else - cat /tmp/deploy-request-error - exit 1 - fi - deploy_number="$(echo "$deploy_response" | jq -r '.number')" - - if [ -z "$deploy_number" ] || [ "$deploy_number" = "null" ]; then - echo "Could not read deploy request number" - exit 1 - fi - - pscale deploy-request review "$database" "$deploy_number" --org "$organization" --approve || true - pscale deploy-request deploy "$database" "$deploy_number" --org "$organization" - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} - PLANETSCALE_SERVICE_TOKEN_ID: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} - PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} - STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} - HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} - SENTRY_RELEASE: web@${{ github.sha }} - VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} - VITE_SENTRY_RELEASE: web@${{ github.sha }} From 418a9e4e66c794d7ce2ef49db974274f211d81ab Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:58:39 -0500 Subject: [PATCH 036/112] fix(stats): restore model page rendering --- packages/stats/app/src/routes/[lab]/[model].tsx | 1 - packages/stats/core/src/domain/home.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index c9b7c33ee98..6869764f333 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -340,7 +340,6 @@ function ModelOverview(props: { data: StatsModelData | null }) { {(data) => (
- 0 ? Math.round(current.totalTokens / current.sessions) : 0, From c0dc6e50a7244c11028c553505f310a03d427488 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 06:16:02 -0500 Subject: [PATCH 037/112] fix(stats): restore worker runtime --- packages/stats/app/src/routes/[lab]/[model].tsx | 4 ++-- packages/stats/app/src/routes/[lab]/index.tsx | 4 ++-- packages/stats/app/src/routes/api/health.ts | 4 ++-- packages/stats/app/src/routes/index.tsx | 4 ++-- packages/stats/app/src/stats-runtime.ts | 12 ++++++++++++ 5 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 packages/stats/app/src/stats-runtime.ts diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 6869764f333..f865690df7b 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -14,7 +14,6 @@ import { type StatsModelData, type UsageRange, } from "@opencode-ai/stats-core/domain/home" -import { runtime } from "@opencode-ai/stats-core/runtime" import { createAsync, query, useParams } from "@solidjs/router" import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" @@ -96,7 +95,8 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a const getModelData = query(async (lab: string, model: string) => { "use server" - return runtime.runPromise(getStatsModelData(model, lab)) + const { statsRuntime } = await import("../../stats-runtime") + return statsRuntime.runPromise(getStatsModelData(model, lab)) }, "getStatsModelData") export default function StatsModel() { diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 1c4cc7cc08a..e1af50fa4ca 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -6,7 +6,6 @@ import { type ModelUsagePoint, type StatsLabData, } from "@opencode-ai/stats-core/domain/home" -import { runtime } from "@opencode-ai/stats-core/runtime" import { createAsync, query, useParams } from "@solidjs/router" import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" @@ -46,7 +45,8 @@ const labFooterLinks: readonly HeaderLink[] = [ const getLabData = query(async (lab: string) => { "use server" - return runtime.runPromise(getStatsLabData(lab)) + const { statsRuntime } = await import("../../stats-runtime") + return statsRuntime.runPromise(getStatsLabData(lab)) }, "getStatsLabData") export default function StatsLab() { diff --git a/packages/stats/app/src/routes/api/health.ts b/packages/stats/app/src/routes/api/health.ts index fe7abc9a7f0..2a07bfc4e3e 100644 --- a/packages/stats/app/src/routes/api/health.ts +++ b/packages/stats/app/src/routes/api/health.ts @@ -1,10 +1,10 @@ import { AppConfig } from "@opencode-ai/stats-core/config" -import { runtime } from "@opencode-ai/stats-core/runtime" import { Effect } from "effect" export async function GET() { + const { statsRuntime } = await import("../../stats-runtime") return Response.json( - await runtime.runPromise( + await statsRuntime.runPromise( Effect.gen(function* () { const config = yield* AppConfig return { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 862120d033f..2439369b65f 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -21,7 +21,6 @@ import { type TokenCostEntry, type UsagePoint, } from "@opencode-ai/stats-core/domain/home" -import { runtime } from "@opencode-ai/stats-core/runtime" import { createAsync, query } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" @@ -109,7 +108,8 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a const getData = query(async () => { "use server" - return runtime.runPromise(getStatsHomeData()) + const { statsRuntime } = await import("../stats-runtime") + return statsRuntime.runPromise(getStatsHomeData()) }, "getStatsHomeData") export default function StatsHome() { diff --git a/packages/stats/app/src/stats-runtime.ts b/packages/stats/app/src/stats-runtime.ts new file mode 100644 index 00000000000..8267f41cc71 --- /dev/null +++ b/packages/stats/app/src/stats-runtime.ts @@ -0,0 +1,12 @@ +import { AppConfig } from "@opencode-ai/stats-core/config" +import { layer } from "@opencode-ai/stats-core/database" +import { GeoStatRepo } from "@opencode-ai/stats-core/domain/geo" +import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model" +import { ProviderStatRepo } from "@opencode-ai/stats-core/domain/provider" +import { Layer, ManagedRuntime } from "effect" + +const repoLayer = Layer.mergeAll(ModelStatRepo.layer, ProviderStatRepo.layer, GeoStatRepo.layer).pipe( + Layer.provide(layer), +) + +export const statsRuntime = ManagedRuntime.make(Layer.mergeAll(AppConfig.layer, layer, repoLayer)) From a0aee82be9ae5398479b64626bb4b41e75f31ce6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 06:22:26 -0500 Subject: [PATCH 038/112] fix(stats): inline worker runtime import --- packages/stats/app/src/routes/[lab]/[model].tsx | 2 +- packages/stats/app/src/routes/[lab]/index.tsx | 2 +- packages/stats/app/src/routes/api/health.ts | 2 +- packages/stats/app/src/routes/index.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index f865690df7b..838fa6925c8 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -26,6 +26,7 @@ import { type ModelCatalogCost, type ModelCatalogEntry, } from "../model-catalog" +import { statsRuntime } from "../../stats-runtime" import { applyThemePreference, Footer, @@ -95,7 +96,6 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a const getModelData = query(async (lab: string, model: string) => { "use server" - const { statsRuntime } = await import("../../stats-runtime") return statsRuntime.runPromise(getStatsModelData(model, lab)) }, "getStatsModelData") diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index e1af50fa4ca..fc448263971 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -16,6 +16,7 @@ import { type ModelCatalogEntry, type ModelCatalogLab, } from "../model-catalog" +import { statsRuntime } from "../../stats-runtime" import { applyThemePreference, Footer, @@ -45,7 +46,6 @@ const labFooterLinks: readonly HeaderLink[] = [ const getLabData = query(async (lab: string) => { "use server" - const { statsRuntime } = await import("../../stats-runtime") return statsRuntime.runPromise(getStatsLabData(lab)) }, "getStatsLabData") diff --git a/packages/stats/app/src/routes/api/health.ts b/packages/stats/app/src/routes/api/health.ts index 2a07bfc4e3e..b648adb2070 100644 --- a/packages/stats/app/src/routes/api/health.ts +++ b/packages/stats/app/src/routes/api/health.ts @@ -1,8 +1,8 @@ import { AppConfig } from "@opencode-ai/stats-core/config" import { Effect } from "effect" +import { statsRuntime } from "../../stats-runtime" export async function GET() { - const { statsRuntime } = await import("../../stats-runtime") return Response.json( await statsRuntime.runPromise( Effect.gen(function* () { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 2439369b65f..90ba9ce9fff 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -26,6 +26,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, import { getRequestEvent } from "solid-js/web" import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" import type { GeometryCollection, Topology } from "topojson-specification" +import { statsRuntime } from "../stats-runtime" import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog" import { applyThemePreference, @@ -108,7 +109,6 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a const getData = query(async () => { "use server" - const { statsRuntime } = await import("../stats-runtime") return statsRuntime.runPromise(getStatsHomeData()) }, "getStatsHomeData") From 1a111be49457c1e274f2bd473c88ec047806925f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 06:30:15 -0500 Subject: [PATCH 039/112] fix(stats): run worker effects directly --- packages/stats/app/src/routes/[lab]/[model].tsx | 4 ++-- packages/stats/app/src/routes/[lab]/index.tsx | 4 ++-- packages/stats/app/src/routes/api/health.ts | 4 ++-- packages/stats/app/src/routes/index.tsx | 4 ++-- packages/stats/app/src/stats-runtime.ts | 8 ++++++-- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 838fa6925c8..a068ed0054f 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -26,7 +26,7 @@ import { type ModelCatalogCost, type ModelCatalogEntry, } from "../model-catalog" -import { statsRuntime } from "../../stats-runtime" +import { runStatsEffect } from "../../stats-runtime" import { applyThemePreference, Footer, @@ -96,7 +96,7 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a const getModelData = query(async (lab: string, model: string) => { "use server" - return statsRuntime.runPromise(getStatsModelData(model, lab)) + return runStatsEffect(getStatsModelData(model, lab)) }, "getStatsModelData") export default function StatsModel() { diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index fc448263971..9d71f7b6442 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -16,7 +16,7 @@ import { type ModelCatalogEntry, type ModelCatalogLab, } from "../model-catalog" -import { statsRuntime } from "../../stats-runtime" +import { runStatsEffect } from "../../stats-runtime" import { applyThemePreference, Footer, @@ -46,7 +46,7 @@ const labFooterLinks: readonly HeaderLink[] = [ const getLabData = query(async (lab: string) => { "use server" - return statsRuntime.runPromise(getStatsLabData(lab)) + return runStatsEffect(getStatsLabData(lab)) }, "getStatsLabData") export default function StatsLab() { diff --git a/packages/stats/app/src/routes/api/health.ts b/packages/stats/app/src/routes/api/health.ts index b648adb2070..81e60f82e9a 100644 --- a/packages/stats/app/src/routes/api/health.ts +++ b/packages/stats/app/src/routes/api/health.ts @@ -1,10 +1,10 @@ import { AppConfig } from "@opencode-ai/stats-core/config" import { Effect } from "effect" -import { statsRuntime } from "../../stats-runtime" +import { runStatsEffect } from "../../stats-runtime" export async function GET() { return Response.json( - await statsRuntime.runPromise( + await runStatsEffect( Effect.gen(function* () { const config = yield* AppConfig return { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 90ba9ce9fff..1d46d43a136 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -26,7 +26,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, import { getRequestEvent } from "solid-js/web" import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" import type { GeometryCollection, Topology } from "topojson-specification" -import { statsRuntime } from "../stats-runtime" +import { runStatsEffect } from "../stats-runtime" import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog" import { applyThemePreference, @@ -109,7 +109,7 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a const getData = query(async () => { "use server" - return statsRuntime.runPromise(getStatsHomeData()) + return runStatsEffect(getStatsHomeData()) }, "getStatsHomeData") export default function StatsHome() { diff --git a/packages/stats/app/src/stats-runtime.ts b/packages/stats/app/src/stats-runtime.ts index 8267f41cc71..15beb678b2f 100644 --- a/packages/stats/app/src/stats-runtime.ts +++ b/packages/stats/app/src/stats-runtime.ts @@ -3,10 +3,14 @@ import { layer } from "@opencode-ai/stats-core/database" import { GeoStatRepo } from "@opencode-ai/stats-core/domain/geo" import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model" import { ProviderStatRepo } from "@opencode-ai/stats-core/domain/provider" -import { Layer, ManagedRuntime } from "effect" +import { Effect, Layer } from "effect" +import type { Success } from "effect/Layer" const repoLayer = Layer.mergeAll(ModelStatRepo.layer, ProviderStatRepo.layer, GeoStatRepo.layer).pipe( Layer.provide(layer), ) +const statsLayer = Layer.mergeAll(AppConfig.layer, layer, repoLayer) -export const statsRuntime = ManagedRuntime.make(Layer.mergeAll(AppConfig.layer, layer, repoLayer)) +export function runStatsEffect(effect: Effect.Effect>) { + return Effect.runPromise(Effect.provide(effect, statsLayer)) +} From c6f719e153cfb5fb20daeb4ade588ca9e897e364 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 06:39:49 -0500 Subject: [PATCH 040/112] fix(stats): restore worker data exports --- packages/stats/core/src/domain/home.ts | 39 ++++++++++++++------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index 8427640113b..83a242d6b10 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -130,26 +130,28 @@ type ModelAggregate = { totalCostMicrocents: number } -export const getStatsHomeData: () => Effect.Effect< +export function getStatsHomeData(): Effect.Effect< StatsHomeData, DatabaseError, ModelStatRepo | ProviderStatRepo | GeoStatRepo -> = Effect.fn("StatsHome.getData")(function* () { - const modelStats = yield* ModelStatRepo - const providerStats = yield* ProviderStatRepo - const geoStats = yield* GeoStatRepo - const [modelRows, providerRows, geoRows] = yield* Effect.all( - [modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()], - { concurrency: "unbounded" }, - ) - return buildStatsHomeData(modelRows, providerRows, geoRows) -}) +> { + return Effect.gen(function* () { + const modelStats = yield* ModelStatRepo + const providerStats = yield* ProviderStatRepo + const geoStats = yield* GeoStatRepo + const [modelRows, providerRows, geoRows] = yield* Effect.all( + [modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()], + { concurrency: "unbounded" }, + ) + return buildStatsHomeData(modelRows, providerRows, geoRows) + }) +} -export const getStatsModelData: ( +export function getStatsModelData( model: string, provider?: string, -) => Effect.Effect = Effect.fn("StatsModel.getData")( - function* (model, provider) { +): Effect.Effect { + return Effect.gen(function* () { const modelStats = yield* ModelStatRepo const geoStats = yield* GeoStatRepo const modelRows = yield* modelStats.listDaily() @@ -165,14 +167,15 @@ export const getStatsModelData: ( }), provider, ) - }, -) + }) +} -export const getStatsLabData: (provider: string) => Effect.Effect = - Effect.fn("StatsLab.getData")(function* (provider) { +export function getStatsLabData(provider: string): Effect.Effect { + return Effect.gen(function* () { const modelStats = yield* ModelStatRepo return buildStatsLabData(provider, yield* modelStats.listDaily()) }) +} function buildStatsHomeData( modelRows: ModelStatMetric[], From f96e6aa6ed3f0a9c027da4eb7c4fba248426152b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 06:48:25 -0500 Subject: [PATCH 041/112] fix(stats): bypass worker runtime crash --- packages/stats/app/src/routes/api/health.ts | 18 +-- packages/stats/app/src/stats-runtime.ts | 17 +- packages/stats/core/src/domain/home.ts | 163 +++++++++++++++----- 3 files changed, 128 insertions(+), 70 deletions(-) diff --git a/packages/stats/app/src/routes/api/health.ts b/packages/stats/app/src/routes/api/health.ts index 81e60f82e9a..eac4abb8981 100644 --- a/packages/stats/app/src/routes/api/health.ts +++ b/packages/stats/app/src/routes/api/health.ts @@ -1,19 +1,3 @@ -import { AppConfig } from "@opencode-ai/stats-core/config" -import { Effect } from "effect" -import { runStatsEffect } from "../../stats-runtime" - export async function GET() { - return Response.json( - await runStatsEffect( - Effect.gen(function* () { - const config = yield* AppConfig - return { - ok: true, - app: "stats", - stage: config.stage, - publicUrl: config.publicUrl, - } - }), - ), - ) + return Response.json({ ok: true, app: "stats" }) } diff --git a/packages/stats/app/src/stats-runtime.ts b/packages/stats/app/src/stats-runtime.ts index 15beb678b2f..0fd2da5fdfa 100644 --- a/packages/stats/app/src/stats-runtime.ts +++ b/packages/stats/app/src/stats-runtime.ts @@ -1,16 +1,5 @@ -import { AppConfig } from "@opencode-ai/stats-core/config" -import { layer } from "@opencode-ai/stats-core/database" -import { GeoStatRepo } from "@opencode-ai/stats-core/domain/geo" -import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model" -import { ProviderStatRepo } from "@opencode-ai/stats-core/domain/provider" -import { Effect, Layer } from "effect" -import type { Success } from "effect/Layer" +import { Effect } from "effect" -const repoLayer = Layer.mergeAll(ModelStatRepo.layer, ProviderStatRepo.layer, GeoStatRepo.layer).pipe( - Layer.provide(layer), -) -const statsLayer = Layer.mergeAll(AppConfig.layer, layer, repoLayer) - -export function runStatsEffect(effect: Effect.Effect>) { - return Effect.runPromise(Effect.provide(effect, statsLayer)) +export function runStatsEffect(effect: Effect.Effect) { + return Effect.runPromise(effect) } diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index 83a242d6b10..c8c4d3e134a 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -1,8 +1,9 @@ +import { Client } from "@planetscale/database" import { Effect } from "effect" -import { DatabaseError } from "../database" -import { GeoStatRepo, type GeoStatMetric } from "./geo" -import { ModelStatRepo, type ModelStatMetric } from "./model" -import { ProviderStatRepo, type ProviderStatMetric } from "./provider" +import { Resource } from "sst/resource" +import type { GeoStatMetric } from "./geo" +import type { ModelStatMetric } from "./model" +import type { ProviderStatMetric } from "./provider" export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise" export type TokenProduct = "Zen" | "Go" | "Enterprise" @@ -91,6 +92,14 @@ export type StatsHomeData = { country: Record } +export class StatsDataError extends Error { + override name = "StatsDataError" + + constructor(readonly cause: unknown) { + super("Failed to load stats data") + } +} + const DAY_MS = 86_400_000 const TOKEN_SCALE = 1_000_000 const DOLLARS_PER_MICROCENT = 1 / 100_000_000 @@ -130,53 +139,129 @@ type ModelAggregate = { totalCostMicrocents: number } -export function getStatsHomeData(): Effect.Effect< - StatsHomeData, - DatabaseError, - ModelStatRepo | ProviderStatRepo | GeoStatRepo -> { - return Effect.gen(function* () { - const modelStats = yield* ModelStatRepo - const providerStats = yield* ProviderStatRepo - const geoStats = yield* GeoStatRepo - const [modelRows, providerRows, geoRows] = yield* Effect.all( - [modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()], - { concurrency: "unbounded" }, - ) - return buildStatsHomeData(modelRows, providerRows, geoRows) +type RawRow = Record + +export function getStatsHomeData(): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const [modelRows, providerRows, geoRows] = await Promise.all([listModelDaily(), listProviderDaily(), listGeoDaily()]) + return buildStatsHomeData(modelRows, providerRows, geoRows) + }, + catch: (cause) => new StatsDataError(cause), }) } export function getStatsModelData( model: string, provider?: string, -): Effect.Effect { - return Effect.gen(function* () { - const modelStats = yield* ModelStatRepo - const geoStats = yield* GeoStatRepo - const modelRows = yield* modelStats.listDaily() - const normalized = modelRows.flatMap(normalizeStatRow) - const resolvedModel = resolveModelName(model, normalized, provider) - if (!resolvedModel) return null - return buildStatsModelData( - resolvedModel, - modelRows, - yield* geoStats.listDaily({ - model: resolvedModel, - provider: resolveModelProvider(resolvedModel, normalized, provider), - }), - provider, - ) +): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const modelRows = await listModelDaily() + const normalized = modelRows.flatMap(normalizeStatRow) + const resolvedModel = resolveModelName(model, normalized, provider) + if (!resolvedModel) return null + return buildStatsModelData( + resolvedModel, + modelRows, + await listGeoDaily({ + model: resolvedModel, + provider: resolveModelProvider(resolvedModel, normalized, provider), + }), + provider, + ) + }, + catch: (cause) => new StatsDataError(cause), }) } -export function getStatsLabData(provider: string): Effect.Effect { - return Effect.gen(function* () { - const modelStats = yield* ModelStatRepo - return buildStatsLabData(provider, yield* modelStats.listDaily()) +export function getStatsLabData(provider: string): Effect.Effect { + return Effect.tryPromise({ + try: async () => buildStatsLabData(provider, await listModelDaily()), + catch: (cause) => new StatsDataError(cause), }) } +async function listModelDaily(): Promise { + return (await queryRows(`select period_key, updated_at, tier, provider, model, sessions, unique_users, input_tokens, + output_tokens, reasoning_tokens, cache_read_tokens, total_tokens, input_cost_microcents, output_cost_microcents, + total_cost_microcents from model_stat where grain = 'day' and client = 'all' and source = 'all' + and tier in ('Go', 'go') order by period_key`)).map((row) => ({ + periodKey: stringValue(row.period_key), + updatedAt: dateValue(row.updated_at), + tier: stringValue(row.tier), + provider: stringValue(row.provider), + model: stringValue(row.model), + sessions: numberValue(row.sessions), + uniqueUsers: numberValue(row.unique_users), + inputTokens: numberValue(row.input_tokens), + outputTokens: numberValue(row.output_tokens), + reasoningTokens: numberValue(row.reasoning_tokens), + cacheReadTokens: numberValue(row.cache_read_tokens), + totalTokens: numberValue(row.total_tokens), + inputCostMicrocents: numberValue(row.input_cost_microcents), + outputCostMicrocents: numberValue(row.output_cost_microcents), + totalCostMicrocents: numberValue(row.total_cost_microcents), + })) +} + +async function listProviderDaily(): Promise { + return (await queryRows(`select period_key, updated_at, tier, provider, total_tokens from provider_stat + where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') order by period_key`)).map( + (row) => ({ + periodKey: stringValue(row.period_key), + updatedAt: dateValue(row.updated_at), + tier: stringValue(row.tier), + provider: stringValue(row.provider), + totalTokens: numberValue(row.total_tokens), + }), + ) +} + +async function listGeoDaily(opts?: { provider?: string; model?: string }): Promise { + const scope = + opts?.model && opts.provider + ? "and provider = ? and model = ?" + : opts?.model + ? "and model = ?" + : "and provider = 'all' and model = 'all'" + const params = opts?.model && opts.provider ? [opts.provider, opts.model] : opts?.model ? [opts.model] : [] + return (await queryRows( + `select period_key, updated_at, tier, provider, model, country, continent, total_tokens from geo_stat + where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') ${scope} order by period_key`, + params, + )).map((row) => ({ + periodKey: stringValue(row.period_key), + updatedAt: dateValue(row.updated_at), + tier: stringValue(row.tier), + provider: stringValue(row.provider), + model: stringValue(row.model), + country: stringValue(row.country), + continent: stringValue(row.continent), + totalTokens: numberValue(row.total_tokens), + })) +} + +async function queryRows(query: string, params: string[] = []) { + return (await new Client({ url: databaseUrl() }).execute(query, params)).rows as RawRow[] +} + +function databaseUrl() { + return process.env.DATABASE_URL ?? Resource.StatsDatabase.url +} + +function stringValue(value: unknown) { + return value == null ? "" : String(value) +} + +function numberValue(value: unknown) { + return Number(value ?? 0) +} + +function dateValue(value: unknown) { + return value instanceof Date ? value : new Date(stringValue(value)) +} + function buildStatsHomeData( modelRows: ModelStatMetric[], providerRows: ProviderStatMetric[], From 7a9337da8a8a83991a22dd462a09faef10c35546 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 21 Jun 2026 11:49:50 +0000 Subject: [PATCH 042/112] chore: generate --- packages/stats/core/src/domain/home.ts | 42 +++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index c8c4d3e134a..0a152af29fa 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -144,7 +144,11 @@ type RawRow = Record export function getStatsHomeData(): Effect.Effect { return Effect.tryPromise({ try: async () => { - const [modelRows, providerRows, geoRows] = await Promise.all([listModelDaily(), listProviderDaily(), listGeoDaily()]) + const [modelRows, providerRows, geoRows] = await Promise.all([ + listModelDaily(), + listProviderDaily(), + listGeoDaily(), + ]) return buildStatsHomeData(modelRows, providerRows, geoRows) }, catch: (cause) => new StatsDataError(cause), @@ -183,10 +187,12 @@ export function getStatsLabData(provider: string): Effect.Effect { - return (await queryRows(`select period_key, updated_at, tier, provider, model, sessions, unique_users, input_tokens, + return ( + await queryRows(`select period_key, updated_at, tier, provider, model, sessions, unique_users, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, total_tokens, input_cost_microcents, output_cost_microcents, total_cost_microcents from model_stat where grain = 'day' and client = 'all' and source = 'all' - and tier in ('Go', 'go') order by period_key`)).map((row) => ({ + and tier in ('Go', 'go') order by period_key`) + ).map((row) => ({ periodKey: stringValue(row.period_key), updatedAt: dateValue(row.updated_at), tier: stringValue(row.tier), @@ -206,16 +212,16 @@ async function listModelDaily(): Promise { } async function listProviderDaily(): Promise { - return (await queryRows(`select period_key, updated_at, tier, provider, total_tokens from provider_stat - where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') order by period_key`)).map( - (row) => ({ - periodKey: stringValue(row.period_key), - updatedAt: dateValue(row.updated_at), - tier: stringValue(row.tier), - provider: stringValue(row.provider), - totalTokens: numberValue(row.total_tokens), - }), - ) + return ( + await queryRows(`select period_key, updated_at, tier, provider, total_tokens from provider_stat + where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') order by period_key`) + ).map((row) => ({ + periodKey: stringValue(row.period_key), + updatedAt: dateValue(row.updated_at), + tier: stringValue(row.tier), + provider: stringValue(row.provider), + totalTokens: numberValue(row.total_tokens), + })) } async function listGeoDaily(opts?: { provider?: string; model?: string }): Promise { @@ -226,11 +232,13 @@ async function listGeoDaily(opts?: { provider?: string; model?: string }): Promi ? "and model = ?" : "and provider = 'all' and model = 'all'" const params = opts?.model && opts.provider ? [opts.provider, opts.model] : opts?.model ? [opts.model] : [] - return (await queryRows( - `select period_key, updated_at, tier, provider, model, country, continent, total_tokens from geo_stat + return ( + await queryRows( + `select period_key, updated_at, tier, provider, model, country, continent, total_tokens from geo_stat where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') ${scope} order by period_key`, - params, - )).map((row) => ({ + params, + ) + ).map((row) => ({ periodKey: stringValue(row.period_key), updatedAt: dateValue(row.updated_at), tier: stringValue(row.tier), From c780d7cee7321353c6196796f34e4d3120e2d4ee Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 21 Jun 2026 14:05:49 +0200 Subject: [PATCH 043/112] feat(plugin): add v2 effect host (#33111) --- bun.lock | 2 + packages/core/package.json | 1 + packages/core/src/agent.ts | 23 +- packages/core/src/catalog.ts | 144 ++--- packages/core/src/command.ts | 21 +- packages/core/src/config/plugin/agent.ts | 122 ++-- packages/core/src/config/plugin/command.ts | 65 +- packages/core/src/config/plugin/provider.ts | 217 +++---- packages/core/src/config/plugin/reference.ts | 81 ++- packages/core/src/config/plugin/skill.ts | 69 +-- packages/core/src/integration.ts | 98 +-- packages/core/src/model-request.ts | 2 +- packages/core/src/model.ts | 19 +- packages/core/src/npm.ts | 50 +- packages/core/src/permission/schema.ts | 2 +- packages/core/src/plugin.ts | 63 +- packages/core/src/plugin/agent.ts | 29 +- packages/core/src/plugin/boot.ts | 110 ++-- packages/core/src/plugin/command.ts | 24 +- packages/core/src/plugin/host.ts | 236 +++++++ packages/core/src/plugin/models-dev.ts | 45 +- packages/core/src/plugin/provider/alibaba.ts | 15 +- .../src/plugin/provider/amazon-bedrock.ts | 24 +- .../core/src/plugin/provider/anthropic.ts | 19 +- packages/core/src/plugin/provider/azure.ts | 41 +- packages/core/src/plugin/provider/cerebras.ts | 23 +- .../plugin/provider/cloudflare-ai-gateway.ts | 15 +- .../plugin/provider/cloudflare-workers-ai.ts | 24 +- packages/core/src/plugin/provider/cohere.ts | 15 +- .../core/src/plugin/provider/deepinfra.ts | 15 +- packages/core/src/plugin/provider/dynamic.ts | 21 +- packages/core/src/plugin/provider/gateway.ts | 15 +- .../src/plugin/provider/github-copilot.ts | 40 +- packages/core/src/plugin/provider/gitlab.ts | 20 +- .../core/src/plugin/provider/google-vertex.ts | 46 +- packages/core/src/plugin/provider/google.ts | 15 +- packages/core/src/plugin/provider/groq.ts | 15 +- packages/core/src/plugin/provider/kilo.ts | 14 +- .../core/src/plugin/provider/llmgateway.ts | 18 +- packages/core/src/plugin/provider/mistral.ts | 15 +- packages/core/src/plugin/provider/nvidia.ts | 14 +- .../src/plugin/provider/openai-compatible.ts | 15 +- packages/core/src/plugin/provider/openai.ts | 44 +- packages/core/src/plugin/provider/opencode.ts | 18 +- .../core/src/plugin/provider/openrouter.ts | 19 +- .../core/src/plugin/provider/perplexity.ts | 15 +- .../core/src/plugin/provider/sap-ai-core.ts | 26 +- .../src/plugin/provider/snowflake-cortex.ts | 15 +- .../core/src/plugin/provider/togetherai.ts | 15 +- packages/core/src/plugin/provider/venice.ts | 15 +- packages/core/src/plugin/provider/vercel.ts | 19 +- packages/core/src/plugin/provider/xai.ts | 20 +- packages/core/src/plugin/provider/zenmux.ts | 14 +- packages/core/src/plugin/skill.ts | 15 +- packages/core/src/provider.ts | 8 +- packages/core/src/reference.ts | 21 +- packages/core/src/session/runner/model.ts | 12 +- packages/core/src/skill.ts | 17 +- packages/core/src/state.ts | 160 ++--- packages/core/src/tool/application-tools.ts | 14 +- packages/core/test/agent.test.ts | 43 +- packages/core/test/catalog.test.ts | 186 ++---- packages/core/test/command.test.ts | 3 +- packages/core/test/config/agent.test.ts | 20 +- packages/core/test/config/command.test.ts | 4 +- packages/core/test/config/provider.test.ts | 31 +- packages/core/test/config/skill.test.ts | 42 +- packages/core/test/integration.test.ts | 22 +- packages/core/test/location-layer.test.ts | 56 +- packages/core/test/npm.test.ts | 2 +- packages/core/test/permission.test.ts | 12 +- packages/core/test/plugin.test.ts | 17 +- packages/core/test/plugin/command.test.ts | 12 +- packages/core/test/plugin/host.ts | 317 ++++++++++ packages/core/test/plugin/models-dev.test.ts | 12 +- .../core/test/plugin/provider-alibaba.test.ts | 10 +- .../plugin/provider-amazon-bedrock.test.ts | 41 +- .../test/plugin/provider-anthropic.test.ts | 22 +- .../provider-azure-cognitive-services.test.ts | 24 +- .../core/test/plugin/provider-azure.test.ts | 103 +-- .../test/plugin/provider-cerebras.test.ts | 22 +- .../provider-cloudflare-ai-gateway.test.ts | 24 +- .../provider-cloudflare-workers-ai.test.ts | 96 +-- .../core/test/plugin/provider-cohere.test.ts | 8 +- .../test/plugin/provider-deepinfra.test.ts | 12 +- .../core/test/plugin/provider-dynamic.test.ts | 18 +- .../core/test/plugin/provider-gateway.test.ts | 8 +- .../plugin/provider-github-copilot.test.ts | 28 +- .../core/test/plugin/provider-gitlab.test.ts | 117 +--- .../provider-google-vertex-anthropic.test.ts | 32 +- .../plugin/provider-google-vertex.test.ts | 50 +- .../core/test/plugin/provider-google.test.ts | 8 +- .../core/test/plugin/provider-groq.test.ts | 12 +- packages/core/test/plugin/provider-helper.ts | 40 +- .../core/test/plugin/provider-kilo.test.ts | 27 +- .../test/plugin/provider-llmgateway.test.ts | 26 +- .../core/test/plugin/provider-mistral.test.ts | 12 +- .../core/test/plugin/provider-nvidia.test.ts | 25 +- .../plugin/provider-openai-compatible.test.ts | 10 +- .../core/test/plugin/provider-openai.test.ts | 21 +- .../test/plugin/provider-opencode.test.ts | 85 ++- .../test/plugin/provider-openrouter.test.ts | 31 +- .../test/plugin/provider-perplexity.test.ts | 12 +- .../test/plugin/provider-sap-ai-core.test.ts | 9 +- .../plugin/provider-snowflake-cortex.test.ts | 14 +- .../test/plugin/provider-togetherai.test.ts | 10 +- .../core/test/plugin/provider-venice.test.ts | 10 +- .../core/test/plugin/provider-vercel.test.ts | 27 +- .../core/test/plugin/provider-xai.test.ts | 32 +- .../core/test/plugin/provider-zenmux.test.ts | 30 +- packages/core/test/plugin/skill.test.ts | 3 +- packages/core/test/reference.test.ts | 9 +- .../core/test/session-runner-model.test.ts | 2 +- packages/core/test/session-runner.test.ts | 8 +- packages/core/test/skill.test.ts | 8 +- packages/core/test/state.test.ts | 87 ++- packages/core/test/tool-skill.test.ts | 4 +- packages/opencode/src/cli/cmd/debug/v2.ts | 8 +- packages/plugin/package.json | 4 +- packages/plugin/src/v2/effect/PLAN.md | 515 +++++++++++++++ packages/plugin/src/v2/effect/README.md | 585 ++++++++++++++++++ packages/plugin/src/v2/effect/agent.ts | 17 + packages/plugin/src/v2/effect/aisdk.ts | 21 + packages/plugin/src/v2/effect/catalog.ts | 41 ++ packages/plugin/src/v2/effect/command.ts | 15 + packages/plugin/src/v2/effect/event.ts | 10 + packages/plugin/src/v2/effect/filesystem.ts | 17 + packages/plugin/src/v2/effect/host.ts | 27 + packages/plugin/src/v2/effect/index.ts | 17 + packages/plugin/src/v2/effect/integration.ts | 36 ++ packages/plugin/src/v2/effect/location.ts | 6 + packages/plugin/src/v2/effect/npm.ts | 11 + packages/plugin/src/v2/effect/path.ts | 8 + packages/plugin/src/v2/effect/plugin.ts | 11 + packages/plugin/src/v2/effect/reference.ts | 13 + packages/plugin/src/v2/effect/registration.ts | 16 + packages/plugin/src/v2/effect/skill.ts | 18 + packages/sdk/js/package.json | 3 +- packages/server/src/handlers/provider.ts | 17 +- 139 files changed, 3749 insertions(+), 1952 deletions(-) create mode 100644 packages/core/src/plugin/host.ts create mode 100644 packages/core/test/plugin/host.ts create mode 100644 packages/plugin/src/v2/effect/PLAN.md create mode 100644 packages/plugin/src/v2/effect/README.md create mode 100644 packages/plugin/src/v2/effect/agent.ts create mode 100644 packages/plugin/src/v2/effect/aisdk.ts create mode 100644 packages/plugin/src/v2/effect/catalog.ts create mode 100644 packages/plugin/src/v2/effect/command.ts create mode 100644 packages/plugin/src/v2/effect/event.ts create mode 100644 packages/plugin/src/v2/effect/filesystem.ts create mode 100644 packages/plugin/src/v2/effect/host.ts create mode 100644 packages/plugin/src/v2/effect/index.ts create mode 100644 packages/plugin/src/v2/effect/integration.ts create mode 100644 packages/plugin/src/v2/effect/location.ts create mode 100644 packages/plugin/src/v2/effect/npm.ts create mode 100644 packages/plugin/src/v2/effect/path.ts create mode 100644 packages/plugin/src/v2/effect/plugin.ts create mode 100644 packages/plugin/src/v2/effect/reference.ts create mode 100644 packages/plugin/src/v2/effect/registration.ts create mode 100644 packages/plugin/src/v2/effect/skill.ts diff --git a/bun.lock b/bun.lock index e38e531d994..657424dfef1 100644 --- a/bun.lock +++ b/bun.lock @@ -276,6 +276,7 @@ "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -625,6 +626,7 @@ "name": "@opencode-ai/plugin", "version": "1.17.9", "dependencies": { + "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", diff --git a/packages/core/package.json b/packages/core/package.json index 7e12e5bac1f..2aec5ecc7e5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,6 +91,7 @@ "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index fabf7477d68..18e9e59c0ff 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,7 +1,6 @@ export * as AgentV2 from "./agent" -import { Array, Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" +import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect" import { ModelV2 } from "./model" import { PermissionSchema } from "./permission/schema" import { ProviderV2 } from "./provider" @@ -49,21 +48,19 @@ export interface Selection { } type Data = { - agents: Map + agents: Map> default?: ID } -export type Editor = { +export type Draft = { list: () => readonly Info[] get: (id: ID) => Info | undefined default: (id: ID | undefined) => void - update: (id: ID, fn: (agent: Draft) => void) => void + update: (id: ID, fn: (agent: Types.DeepMutable) => void) => void remove: (id: ID) => void } -export interface Interface { - readonly transform: State.Interface["transform"] - readonly update: State.Interface["update"] +export interface Interface extends State.Transformable { readonly get: (id: ID) => Effect.Effect readonly default: () => Effect.Effect readonly resolve: (id?: ID | string) => Effect.Effect @@ -73,21 +70,19 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Agent") {} -enableMapSet() - export const layer = Layer.effect( Service, Effect.gen(function* () { - const state = State.create({ + const state = State.create({ initial: () => ({ agents: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.fromIterable(draft.agents.values()) as Info[], get: (id) => draft.agents.get(id), default: (id) => { draft.default = id }, update: (id, fn) => { - const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) + const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable) if (!draft.agents.has(id)) draft.agents.set(id, current) fn(current) current.id = id @@ -113,7 +108,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - update: state.update, + rebuild: state.rebuild, get: Effect.fn("AgentV2.get")(function* (id) { return state.get().agents.get(id) }), diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 4156db5c361..d32f9366811 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,36 +1,21 @@ export * as Catalog from "./catalog" -import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" +import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { ModelV2 } from "./model" import { ModelRequest } from "./model-request" -import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" -import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" import { State } from "./state" import { Integration } from "./integration" export type ProviderRecord = { - provider: ProviderV2.Info - models: Map + provider: ProviderV2.MutableInfo + models: Map } export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } -export class ProviderNotFoundError extends Schema.TaggedErrorClass()( - "CatalogV2.ProviderNotFound", - { - providerID: ProviderV2.ID, - }, -) {} - -export class ModelNotFoundError extends Schema.TaggedErrorClass()("CatalogV2.ModelNotFound", { - providerID: ProviderV2.ID, - modelID: ModelV2.ID, -}) {} - export const PolicyActions = Schema.Literals(["provider.use"]) export const Event = { @@ -42,16 +27,16 @@ type Data = { defaultModel?: DefaultModel } -export type Editor = { +export type Draft = { provider: { list: () => readonly ProviderRecord[] get: (providerID: ProviderV2.ID) => ProviderRecord | undefined - update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void + update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void remove: (providerID: ProviderV2.ID) => void } model: { get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined - update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void + update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void default: { get: () => DefaultModel | undefined @@ -60,38 +45,29 @@ export type Editor = { } } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly provider: { - readonly get: (providerID: ProviderV2.ID) => Effect.Effect + readonly get: (providerID: ProviderV2.ID) => Effect.Effect readonly all: () => Effect.Effect readonly available: () => Effect.Effect } readonly model: { - readonly get: ( - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - ) => Effect.Effect + readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect readonly all: () => Effect.Effect readonly available: () => Effect.Effect - readonly default: () => Effect.Effect> - readonly small: (providerID: ProviderV2.ID) => Effect.Effect> + readonly default: () => Effect.Effect + readonly small: (providerID: ProviderV2.ID) => Effect.Effect } } export class Service extends Context.Service()("@opencode/v2/Catalog") {} -enableMapSet() - export const layer = Layer.effect( Service, Effect.gen(function* () { - const location = yield* Location.Service - const plugin = yield* PluginV2.Service const events = yield* EventV2.Service const policy = yield* Policy.Service const integrations = yield* Integration.Service - const scope = yield* Scope.Scope const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { if (provider.disabled) return false @@ -120,32 +96,26 @@ export const layer = Layer.effect( }) } - function* getRecord(providerID: ProviderV2.ID) { - const match = state.get().providers.get(providerID) - if (!match) return yield* new ProviderNotFoundError({ providerID }) - return match - } - - const normalizeApi = (item: Draft | Draft) => { + const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => { if (typeof item.request.body.baseURL !== "string") return item.api.url = item.request.body.baseURL delete item.request.body.baseURL } - const state = State.create({ + const state = State.create({ initial: () => ({ providers: new Map() }), - editor: (draft) => { - const result: Editor = { + draft: (draft) => { + const result: Draft = { provider: { list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], get: (providerID) => draft.providers.get(providerID), update: (providerID, fn) => { let current = draft.providers.get(providerID) if (!current) { - current = castDraft({ - provider: ProviderV2.Info.empty(providerID), - models: new Map(), - }) + current = { + provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, + models: new Map(), + } draft.providers.set(providerID, current) } fn(current.provider) @@ -160,13 +130,14 @@ export const layer = Layer.effect( update: (providerID, modelID, fn) => { let record = draft.providers.get(providerID) if (!record) { - record = castDraft({ - provider: ProviderV2.Info.empty(providerID), - models: new Map(), - }) + record = { + provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, + models: new Map(), + } draft.providers.set(providerID, record) } - const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) + const model = + record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo) if (!record.models.has(modelID)) record.models.set(modelID, model) fn(model) model.id = modelID @@ -186,8 +157,7 @@ export const layer = Layer.effect( } return result }, - finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { - if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) + finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { if (policy.hasStatements()) { for (const record of [...catalog.provider.list()]) { if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { @@ -198,25 +168,13 @@ export const layer = Layer.effect( yield* events.publish(Event.Updated, {}) }), }) - yield* events.subscribe(PluginV2.Event.Added).pipe( - // Plugin registries are location scoped even though the event bus is process scoped. - Stream.filter( - (event) => - event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID, - ), - Stream.runForEach((event) => - state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), - ), - Effect.forkIn(scope, { startImmediately: true }), - ) - const result: Interface = { transform: state.transform, + rebuild: state.rebuild, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { - const record = yield* getRecord(providerID) - return record.provider + return state.get().providers.get(providerID)?.provider }), all: Effect.fn("CatalogV2.provider.all")(function* () { @@ -238,10 +196,10 @@ export const layer = Layer.effect( model: { get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { - const record = yield* getRecord(providerID) + const record = state.get().providers.get(providerID) + if (!record) return const model = record.models.get(modelID) - if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) - return projectModel(model, record.provider) + return model && projectModel(model, record.provider) }), all: Effect.fn("CatalogV2.model.all")(function* () { @@ -250,7 +208,7 @@ export const layer = Layer.effect( Array.flatMap((record) => { return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) }), - Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), ) }), @@ -262,31 +220,30 @@ export const layer = Layer.effect( default: Effect.fn("CatalogV2.model.default")(function* () { const defaultModel = state.get().defaultModel if (defaultModel) { - const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) - if ( - Option.isSome(provider) && - (yield* result.provider.available()).some((item) => item.id === provider.value.id) - ) { - const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) - if (Option.isSome(model) && model.value.enabled) return model + const provider = yield* result.provider.get(defaultModel.providerID) + if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) { + const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID) + if (model?.enabled) return model } } - return pipe( - yield* result.model.available(), - Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), - Array.head, + return Option.getOrUndefined( + pipe( + yield* result.model.available(), + Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), + Array.head, + ), ) }), small: Effect.fn("CatalogV2.model.small")(function* (providerID) { const record = state.get().providers.get(providerID) - if (!record) return Option.none() + if (!record) return const provider = record.provider if (providerID === ProviderV2.ID.opencode) { const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) - if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider)) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider) } const candidates = pipe( @@ -302,7 +259,7 @@ export const layer = Layer.effect( Array.map((model) => ({ model, cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, - age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30), + age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30), small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), })), Array.filter((item) => item.cost > 0 && item.age <= 18), @@ -319,10 +276,12 @@ export const layer = Layer.effect( ) } - return pipe( - candidates, - Array.filter((item) => item.small), - (items) => (items.length > 0 ? pick(items) : pick(candidates)), + return Option.getOrUndefined( + pipe( + candidates, + Array.filter((item) => item.small), + (items) => (items.length > 0 ? pick(items) : pick(candidates)), + ), ) }), }, @@ -336,6 +295,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ export const locationLayer = layer.pipe( Layer.provideMerge(Integration.locationLayer), - Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(Policy.locationLayer), ) diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index f6b8210be12..622702e9946 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,7 +1,6 @@ export * as CommandV2 from "./command" -import { Context, Effect, Layer, Schema } from "effect" -import { castDraft, type Draft } from "immer" +import { Context, Effect, Layer, Schema, Types } from "effect" import { ModelV2 } from "./model" import { State } from "./state" @@ -15,19 +14,17 @@ export class Info extends Schema.Class("CommandV2.Info")({ }) {} export type Data = { - commands: Map + commands: Map> } -export type Editor = { +export type Draft = { list: () => readonly Info[] get: (name: string) => Info | undefined - update: (name: string, update: (command: Draft) => void) => void + update: (name: string, update: (command: Types.DeepMutable) => void) => void remove: (name: string) => void } -export interface Interface { - readonly transform: State.Interface["transform"] - readonly update: State.Interface["update"] +export interface Interface extends State.Transformable { readonly get: (name: string) => Effect.Effect readonly list: () => Effect.Effect } @@ -37,13 +34,13 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.sync(() => { - const state = State.create({ + const state = State.create({ initial: () => ({ commands: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.from(draft.commands.values()) as Info[], get: (name) => draft.commands.get(name), update: (name, update) => { - const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" })) + const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable) if (!draft.commands.has(name)) draft.commands.set(name, current) update(current) current.name = name @@ -55,7 +52,7 @@ export const layer = Layer.effect( }) return Service.of({ - update: state.update, + rebuild: state.rebuild, transform: state.transform, get: Effect.fn("CommandV2.get")(function* (name) { return state.get().commands.get(name) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 36534b0d382..ffc268a0e24 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,5 +1,6 @@ export * as ConfigAgentPlugin from "./agent" +import { define } from "@opencode-ai/plugin/v2/effect" import path from "path" import { Effect, Option, Schema } from "effect" import { AgentV2 } from "../../agent" @@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent" import { ConfigMarkdown } from "../markdown" import { FSUtil } from "../../fs-util" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" import { ConfigAgentV1 } from "../../v1/config/agent" import { ConfigMigrateV1 } from "../../v1/config/migrate" @@ -33,70 +33,70 @@ const agentKeys = new Set([ "permissions", ]) -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-agent"), - effect: Effect.gen(function* () { - const agent = yield* AgentV2.Service +export const Plugin = define({ + id: "config-agent", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([entry]) - return Effect.gen(function* () { - const files = yield* discover(fs, entry.path) - return yield* Effect.forEach(files, (file) => - fs.readFileStringSafe(file.filepath).pipe( - Effect.map((content) => content && decode(file, content)), - Effect.catch(() => Effect.succeed(undefined)), - ), - ).pipe( - Effect.map((documents) => - documents.filter((document): document is Config.Document => document !== undefined), - ), - ) - }) - }).pipe(Effect.map((documents) => documents.flat())) - - yield* agent.update((editor) => { - const global = documents.flatMap((document) => document.info.permissions ?? []) - const configuredDefault = Config.latest(documents, "default_agent") - if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault)) - for (const current of editor.list()) { - editor.update(current.id, (agent) => agent.permissions.push(...global)) - } - - for (const document of documents) { - for (const [id, item] of Object.entries(document.info.agents ?? {})) { - const agentID = AgentV2.ID.make(id) - if (item.disabled) { - editor.remove(agentID) - continue - } - - const exists = editor.get(agentID) !== undefined - editor.update(agentID, (agent) => { - if (!exists) agent.permissions.push(...global) - if (item.model !== undefined) { - const model = ModelV2.parse(item.model) - agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } - } - if (item.variant !== undefined && agent.model !== undefined) { - agent.model.variant = ModelV2.VariantID.make(item.variant) - } - if (item.request !== undefined) { - Object.assign(agent.request.headers, item.request.headers ?? {}) - Object.assign(agent.request.body, item.request.body ?? {}) - } - if (item.system !== undefined) agent.system = item.system - if (item.description !== undefined) agent.description = item.description - if (item.mode !== undefined) agent.mode = item.mode - if (item.hidden !== undefined) agent.hidden = item.hidden - if (item.color !== undefined) agent.color = item.color - if (item.steps !== undefined) agent.steps = item.steps - if (item.permissions !== undefined) agent.permissions.push(...item.permissions) + yield* ctx.agent.transform( + Effect.fn(function* (draft) { + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([entry]) + return Effect.gen(function* () { + const files = yield* discover(fs, entry.path) + return yield* Effect.forEach(files, (file) => + fs.readFileStringSafe(file.filepath).pipe( + Effect.map((content) => content && decode(file, content)), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((documents) => + documents.filter((document): document is Config.Document => document !== undefined), + ), + ) }) + }).pipe(Effect.map((documents) => documents.flat())) + const global = documents.flatMap((document) => document.info.permissions ?? []) + const configuredDefault = Config.latest(documents, "default_agent") + if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) + for (const current of draft.list()) { + draft.update(current.id, (agent) => agent.permissions.push(...global)) } - } - }) + + for (const document of documents) { + for (const [id, item] of Object.entries(document.info.agents ?? {})) { + const agentID = AgentV2.ID.make(id) + if (item.disabled) { + draft.remove(agentID) + continue + } + + const exists = draft.get(agentID) !== undefined + draft.update(agentID, (agent) => { + if (!exists) agent.permissions.push(...global) + if (item.model !== undefined) { + const model = ModelV2.parse(item.model) + agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } + } + if (item.variant !== undefined && agent.model !== undefined) { + agent.model.variant = ModelV2.VariantID.make(item.variant) + } + if (item.request !== undefined) { + Object.assign(agent.request.headers, item.request.headers ?? {}) + Object.assign(agent.request.body, item.request.body ?? {}) + } + if (item.system !== undefined) agent.system = item.system + if (item.description !== undefined) agent.description = item.description + if (item.mode !== undefined) agent.mode = item.mode + if (item.hidden !== undefined) agent.hidden = item.hidden + if (item.color !== undefined) agent.color = item.color + if (item.steps !== undefined) agent.steps = item.steps + if (item.permissions !== undefined) agent.permissions.push(...item.permissions) + }) + } + } + }), + ) }), }) diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index 7e71f306e89..a88c60559e9 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,52 +1,51 @@ export * as ConfigCommandPlugin from "./command" +import { define } from "@opencode-ai/plugin/v2/effect" import path from "path" import { Effect, Option, Schema } from "effect" import { CommandV2 } from "../../command" import { Config } from "../../config" import { FSUtil } from "../../fs-util" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" import { ConfigCommand } from "../command" import { ConfigMarkdown } from "../markdown" const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-command"), - effect: Effect.gen(function* () { - const command = yield* CommandV2.Service +export const Plugin = define({ + id: "config-command", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const transform = yield* command.transform() - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) - return loadDirectory(fs, entry.path).pipe( - Effect.map((commands) => [ - { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, - ]), - ) - }).pipe(Effect.map((documents) => documents.flat())) - - yield* transform((editor) => { - for (const document of documents) { - for (const [name, command] of Object.entries(document.commands ?? {})) { - editor.update(name, (item) => { - item.template = command.template - if (command.description !== undefined) item.description = command.description - if (command.agent !== undefined) item.agent = command.agent - if (command.model !== undefined) { - const model = ModelV2.parse(command.model) - item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } - } - if (command.variant !== undefined && item.model !== undefined) { - item.model.variant = ModelV2.VariantID.make(command.variant) - } - if (command.subtask !== undefined) item.subtask = command.subtask - }) + yield* ctx.command.transform( + Effect.fn(function* (draft) { + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + return loadDirectory(fs, entry.path).pipe( + Effect.map((commands) => [ + { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, + ]), + ) + }).pipe(Effect.map((documents) => documents.flat())) + for (const document of documents) { + for (const [name, command] of Object.entries(document.commands ?? {})) { + draft.update(name, (item) => { + item.template = command.template + if (command.description !== undefined) item.description = command.description + if (command.agent !== undefined) item.agent = command.agent + if (command.model !== undefined) { + const model = ModelV2.parse(command.model) + item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } + } + if (command.variant !== undefined && item.model !== undefined) { + item.model.variant = ModelV2.VariantID.make(command.variant) + } + if (command.subtask !== undefined) item.subtask = command.subtask + }) + } } - } - }) + }), + ) }), }) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 47a3712e3ad..0171fee37bb 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,123 +1,124 @@ export * as ConfigProviderPlugin from "./provider" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -import { Catalog } from "../../catalog" import { Config } from "../../config" -import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-provider"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service +export const Plugin = define({ + id: "config-provider", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const integrations = yield* Integration.Service - const transform = yield* catalog.transform() - const integrationTransform = yield* integrations.transform() - const entries = yield* config.entries() - const files = entries.filter((entry): entry is Config.Document => entry.type === "document") - const configuredIntegrations = new Set( - files.flatMap((file) => - Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), - ), - ) - yield* integrationTransform((integrations) => { - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const integrationID = Integration.ID.make(id) - if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue - integrations.update(integrationID, (integration) => { - integration.name = item.name ?? integration.name - }) - if (item.env !== undefined) { - integrations.method.update({ - integrationID, - method: { type: "env", names: [...item.env] }, + yield* ctx.integration.transform( + Effect.fn(function* (integrations) { + const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => + provider.env === undefined ? [] : [id], + ), + ), + ) + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const integrationID = id + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = item.name ?? integration.name }) - } - } - } - }) - - yield* transform((catalog) => { - const configuredDefault = Config.latest(entries, "model") - if (configuredDefault !== undefined) { - const model = ModelV2.parse(configuredDefault) - catalog.model.default.set(model.providerID, model.modelID) - } - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const providerID = ProviderV2.ID.make(id) - catalog.provider.update(providerID, (provider) => { - if (item.name !== undefined) provider.name = item.name - if (item.api !== undefined) provider.api = { ...item.api } - if (item.request !== undefined) { - Object.assign(provider.request.headers, item.request.headers) - Object.assign(provider.request.body, item.request.body) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) } - }) - const providerApi = catalog.provider.get(providerID)?.provider.api - const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined - - for (const [id, config] of Object.entries(item.models ?? {})) { - catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.api !== undefined) model.api = { ...model.api, ...config.api } - const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage - if (config.capabilities !== undefined) { - model.capabilities = { - tools: config.capabilities.tools, - input: [...config.capabilities.input], - output: [...config.capabilities.output], - } - } - if (config.request !== undefined) { - ModelRequest.assign(model.request, { - headers: config.request.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), - }) - if (config.request.variant !== undefined) model.request.variant = config.request.variant - } - if (config.variants !== undefined) { - for (const variant of config.variants) { - let existing = model.variants.find((item) => item.id === variant.id) - if (!existing) { - existing = { - id: variant.id, - headers: {}, - body: {}, - generation: {}, - options: {}, - } - model.variants.push(existing) - } - ModelRequest.assign(existing, { - headers: variant.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), - }) - } - } - if (config.cost !== undefined) { - model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { - read: cost.cache?.read ?? 0, - write: cost.cache?.write ?? 0, - }, - })) - } - if (config.disabled !== undefined) model.enabled = !config.disabled - if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } - }) } } - } - }) + }), + ) + + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const entries = yield* config.entries() + const files = entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredDefault = Config.latest(entries, "model") + if (configuredDefault !== undefined) { + const model = ModelV2.parse(configuredDefault) + catalog.model.default.set(model.providerID, model.modelID) + } + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = id + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.api !== undefined) provider.api = { ...item.api } + if (item.request !== undefined) { + Object.assign(provider.request.headers, item.request.headers) + Object.assign(provider.request.body, item.request.body) + } + }) + const providerApi = catalog.provider.get(providerID)?.provider.api + const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined + + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, id, (model) => { + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.api !== undefined) model.api = { ...model.api, ...config.api } + const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.request !== undefined) { + ModelRequest.assign(model.request, { + headers: config.request.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), + }) + if (config.request.variant !== undefined) model.request.variant = config.request.variant + } + if (config.variants !== undefined) { + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { + id: variant.id, + headers: {}, + body: {}, + generation: {}, + options: {}, + } + model.variants.push(existing) + } + ModelRequest.assign(existing, { + headers: variant.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), + }) + } + } + if (config.cost !== undefined) { + model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { + read: cost.cache?.read ?? 0, + write: cost.cache?.write ?? 0, + }, + })) + } + if (config.disabled !== undefined) model.enabled = !config.disabled + if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } + }) + } + } + } + }), + ) }), }) diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index 22c7664996d..f511736e11f 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -1,57 +1,52 @@ export * as ConfigReferencePlugin from "./reference" +import { define } from "@opencode-ai/plugin/v2/effect" import path from "path" import { Effect } from "effect" import { Config } from "../../config" import { ConfigReference } from "../reference" -import { Global } from "../../global" -import { Location } from "../../location" -import { PluginV2 } from "../../plugin" import { Reference } from "../../reference" import { AbsolutePath } from "../../schema" -export const Plugin = { - id: PluginV2.ID.make("core/config-reference"), - effect: Effect.gen(function* () { +export const Plugin = define({ + id: "core/config-reference", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const global = yield* Global.Service - const location = yield* Location.Service - const references = yield* Reference.Service - const update = yield* references.transform() - const entries = new Map() - for (const doc of (yield* config.entries()).filter( - (entry): entry is Config.Document => entry.type === "document", - )) { - const directory = doc.path ? path.dirname(doc.path) : location.directory - for (const [name, entry] of Object.entries(doc.info.references ?? {})) { - if (!validAlias(name)) continue - entries.set( - name, - local(entry) - ? new Reference.LocalSource({ - type: "local", - path: AbsolutePath.make( - localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), - ), - description: typeof entry === "string" ? undefined : entry.description, - hidden: typeof entry === "string" ? undefined : entry.hidden, - }) - : new Reference.GitSource({ - type: "git", - repository: typeof entry === "string" ? entry : entry.repository, - branch: typeof entry === "string" ? undefined : entry.branch, - description: typeof entry === "string" ? undefined : entry.description, - hidden: typeof entry === "string" ? undefined : entry.hidden, - }), - ) - } - } - - yield* update((editor) => { - for (const [name, source] of entries) editor.add(name, source) - }) + yield* ctx.reference.transform( + Effect.fn(function* (draft) { + const entries = new Map() + for (const doc of (yield* config.entries()).filter( + (entry): entry is Config.Document => entry.type === "document", + )) { + const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + entries.set( + name, + local(entry) + ? new Reference.LocalSource({ + type: "local", + path: AbsolutePath.make( + localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path), + ), + description: typeof entry === "string" ? undefined : entry.description, + hidden: typeof entry === "string" ? undefined : entry.hidden, + }) + : new Reference.GitSource({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + branch: typeof entry === "string" ? undefined : entry.branch, + description: typeof entry === "string" ? undefined : entry.description, + hidden: typeof entry === "string" ? undefined : entry.hidden, + }), + ) + } + } + for (const [name, source] of entries) draft.add(name, source) + }), + ) }), -} +}) function validAlias(name: string) { return name.length > 0 && !/[\/\s`,]/.test(name) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 30b7a882766..9f6a99d8b1a 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -1,48 +1,45 @@ export * as ConfigSkillPlugin from "./skill" +import { define } from "@opencode-ai/plugin/v2/effect" import path from "path" import { Effect } from "effect" import { Config } from "../../config" -import { Global } from "../../global" -import { Location } from "../../location" -import { PluginV2 } from "../../plugin" import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-skill"), - effect: Effect.gen(function* () { +export const Plugin = define({ + id: "config-skill", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const global = yield* Global.Service - const location = yield* Location.Service - const skill = yield* SkillV2.Service - const transform = yield* skill.transform() - const entries = yield* config.entries() - const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) - - yield* transform((editor) => { - for (const directory of directories) { - editor.source( - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), - ) - editor.source( - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), - ) - } - for (const item of items) { - if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { - editor.source(new SkillV2.UrlSource({ type: "url", url: item })) - continue + yield* ctx.skill.transform( + Effect.fn(function* (draft) { + const entries = yield* config.entries() + const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + for (const directory of directories) { + draft.source( + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), + ) + draft.source( + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), + ) } - const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item - editor.source( - new SkillV2.DirectorySource({ - type: "directory", - path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), - }), - ) - } - }) + for (const item of items) { + if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { + draft.source(new SkillV2.UrlSource({ type: "url", url: item })) + continue + } + const expanded = item.startsWith("~/") ? path.join(ctx.path.home, item.slice(2)) : item + draft.source( + new SkillV2.DirectorySource({ + type: "directory", + path: AbsolutePath.make( + path.isAbsolute(expanded) ? expanded : path.join(ctx.location.directory, expanded), + ), + }), + ) + } + }), + ) }), }) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 90995b1987c..4bd27ffd3c9 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,7 +1,19 @@ export * as Integration from "./integration" -import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" +import { + Cause, + Clock, + Context, + Duration, + Effect, + Exit, + Layer, + Schedule, + Schema, + Scope, + SynchronizedRef, + Types, +} from "effect" import { Credential } from "./credential" import { IntegrationSchema } from "./integration/schema" import { withStatics } from "./schema" @@ -42,12 +54,14 @@ export const SelectPrompt = Schema.Struct({ type: Schema.Literal("select"), key: Schema.String, message: Schema.String, - options: Schema.Array( - Schema.Struct({ - label: Schema.String, - value: Schema.String, - hint: Schema.optional(Schema.String), - }), + options: Schema.mutable( + Schema.Array( + Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: Schema.optional(Schema.String), + }), + ), ), when: Schema.optional(When), }).annotate({ identifier: "Integration.SelectPrompt" }) @@ -60,7 +74,7 @@ export const OAuthMethod = Schema.Struct({ id: MethodID, type: Schema.Literal("oauth"), label: Schema.String, - prompts: Schema.optional(Schema.Array(Prompt)), + prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))), }).annotate({ identifier: "Integration.OAuthMethod" }) export type OAuthMethod = typeof OAuthMethod.Type @@ -72,7 +86,7 @@ export type KeyMethod = typeof KeyMethod.Type export const EnvMethod = Schema.Struct({ type: Schema.Literal("env"), - names: Schema.Array(Schema.String), + names: Schema.mutable(Schema.Array(Schema.String)), }).annotate({ identifier: "Integration.EnvMethod" }) export type EnvMethod = typeof EnvMethod.Type @@ -82,8 +96,8 @@ export type Method = typeof Method.Type export class Info extends Schema.Class("Integration.Info")({ id: ID, name: Schema.String, - methods: Schema.Array(Method), - connections: Schema.Array(IntegrationConnection.Info), + methods: Schema.mutable(Schema.Array(Method)), + connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)), }) {} export type Inputs = Readonly<{ [key: string]: string }> @@ -172,19 +186,19 @@ export type Ref = { } type Entry = { - ref: Ref - methods: Method[] - implementations: Map + ref: Types.DeepMutable + methods: Types.DeepMutable[] + implementations: Map> } type Data = { integrations: Map } -export type Editor = { +export type Draft = { list: () => readonly Ref[] get: (id: ID) => Ref | undefined - update: (id: ID, update: (integration: Draft) => void) => void + update: (id: ID, update: (integration: Types.DeepMutable) => void) => void remove: (id: ID) => void method: { list: (integrationID: ID) => readonly Method[] @@ -193,11 +207,8 @@ export type Editor = { } } -export interface Interface { +export interface Interface extends State.Transformable { /** Registers a scoped transform over the integration registry. */ - readonly transform: State.Interface["transform"] - /** Registers and immediately applies a scoped integration registry update. */ - readonly update: State.Interface["update"] /** Returns one integration with its methods and current connections. */ readonly get: (id: ID) => Effect.Effect /** Returns all integrations with their methods and current connections. */ @@ -252,8 +263,6 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Integration") {} -enableMapSet() - const attemptLifetime = Duration.toMillis(Duration.minutes(10)) const terminalRetention = Duration.toMillis(Duration.minutes(1)) const scrubInterval = Duration.seconds(30) @@ -284,15 +293,17 @@ export const locationLayer = Layer.effect( const events = yield* EventV2.Service const scope = yield* Scope.Scope const attempts = SynchronizedRef.makeUnsafe(new Map()) - const state = State.create({ + const state = State.create({ initial: () => ({ integrations: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[], get: (id) => draft.integrations.get(id)?.ref as Ref | undefined, update: (id, update) => { - const current = - draft.integrations.get(id) ?? - castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() }) + const current = draft.integrations.get(id) ?? { + ref: { id, name: id }, + methods: [], + implementations: new Map(), + } if (!draft.integrations.has(id)) draft.integrations.set(id, current) update(current.ref) current.ref.id = id @@ -301,16 +312,14 @@ export const locationLayer = Layer.effect( method: { list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [], update: (implementation) => { - const current = - draft.integrations.get(implementation.integrationID) ?? - castDraft({ - ref: { - id: implementation.integrationID, - name: implementation.integrationID, - } as Ref, - methods: [], - implementations: new Map(), - }) + const current = draft.integrations.get(implementation.integrationID) ?? { + ref: { + id: implementation.integrationID, + name: implementation.integrationID, + }, + methods: [], + implementations: new Map>(), + } if (!draft.integrations.has(implementation.integrationID)) { draft.integrations.set(implementation.integrationID, current) } @@ -319,10 +328,13 @@ export const locationLayer = Layer.effect( if (method.type !== "oauth" || implementation.method.type !== "oauth") return true return method.id === implementation.method.id }) - if (index === -1) current.methods.push(castDraft(implementation.method)) - else current.methods[index] = castDraft(implementation.method) - if (isOAuthImplementation(implementation)) { - current.implementations.set(implementation.method.id, castDraft(implementation)) + if (index === -1) current.methods.push(implementation.method as Types.DeepMutable) + else current.methods[index] = implementation.method as Types.DeepMutable + if (implementation.method.type === "oauth") { + current.implementations.set( + implementation.method.id, + implementation as Types.DeepMutable, + ) } }, remove: (integrationID, method) => { @@ -434,7 +446,7 @@ export const locationLayer = Layer.effect( return Service.of({ transform: state.transform, - update: state.update, + rebuild: state.rebuild, get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts index f9f4f56936d..5de1f9803dc 100644 --- a/packages/core/src/model-request.ts +++ b/packages/core/src/model-request.ts @@ -33,7 +33,7 @@ export type Request = typeof Request.Type interface MutableRequest { headers: Record body: Record - generation?: Generation + generation?: Record options?: Record } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3b0beece55f..8a06229e040 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,5 +1,4 @@ -import { DateTime, Schema } from "effect" -import { DateTimeUtcFromMillis } from "effect/Schema" +import { Schema, Types } from "effect" import { ProviderV2 } from "./provider" import { ModelRequest } from "./model-request" @@ -16,8 +15,8 @@ export type Family = typeof Family.Type export const Capabilities = Schema.Struct({ tools: Schema.Boolean, // mime patterns, image, audio, video/*, text/* - input: Schema.String.pipe(Schema.Array), - output: Schema.String.pipe(Schema.Array), + input: Schema.String.pipe(Schema.Array, Schema.mutable), + output: Schema.String.pipe(Schema.Array, Schema.mutable), }) export type Capabilities = typeof Capabilities.Type @@ -67,11 +66,11 @@ export class Info extends Schema.Class("ModelV2.Info")({ variants: Schema.Struct({ id: VariantID, ...ModelRequest.Request.fields, - }).pipe(Schema.Array), + }).pipe(Schema.Array, Schema.mutable), time: Schema.Struct({ - released: DateTimeUtcFromMillis, + released: Schema.Finite, }), - cost: Cost.pipe(Schema.Array), + cost: Cost.pipe(Schema.Array, Schema.mutable), status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), enabled: Schema.Boolean, limit: Schema.Struct({ @@ -103,7 +102,7 @@ export class Info extends Schema.Class("ModelV2.Info")({ }, variants: [], time: { - released: DateTime.makeUnsafe(0), + released: 0, }, cost: [], status: "active", @@ -116,6 +115,10 @@ export class Info extends Schema.Class("ModelV2.Info")({ } } +export type MutableInfo = Omit, "api"> & { + api: ProviderV2.MutableApi +} + export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { const [providerID, ...modelID] = input.split("/") return { diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 48ad74c1807..3ad8beb0a77 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -20,7 +20,7 @@ export class InstallFailedError extends Schema.TaggedErrorClass + readonly entrypoint?: string } export interface Interface { @@ -34,7 +34,7 @@ export interface Interface { }[] }, ) => Effect.Effect - readonly which: (pkg: string, bin?: string) => Effect.Effect> + readonly which: (pkg: string, bin?: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Npm") {} @@ -47,12 +47,11 @@ export function sanitize(pkg: string) { } const resolveEntryPoint = (name: string, dir: string): EntryPoint => { - let entrypoint: Option.Option + let entrypoint: string | undefined try { - const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - entrypoint = Option.some(resolved) + entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) } catch { - entrypoint = Option.none() + entrypoint = undefined } return { directory: dir, @@ -130,7 +129,7 @@ export const layer = Layer.effect( const first = tree.edgesOut.values().next().value?.to if (!first) { const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) - if (Option.isSome(result.entrypoint)) return result + if (result.entrypoint) return result return yield* new InstallFailedError({ add: [pkg], dir }) } return resolveEntryPoint(first.name, first.path) @@ -219,22 +218,24 @@ export const layer = Layer.effect( return Option.some(files[0]) }) - return yield* Effect.gen(function* () { - const bin = yield* pick() - if (Option.isSome(bin)) { - return Option.some(path.join(binDir, bin.value)) - } + return Option.getOrUndefined( + yield* Effect.gen(function* () { + const bin = yield* pick() + if (Option.isSome(bin)) { + return Option.some(path.join(binDir, bin.value)) + } - yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) + yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) - yield* add(pkg) + yield* add(pkg) - const resolved = yield* pick() - if (Option.isNone(resolved)) return Option.none() - return Option.some(path.join(binDir, resolved.value)) - }).pipe( - Effect.scoped, - Effect.orElseSucceed(() => Option.none()), + const resolved = yield* pick() + if (Option.isNone(resolved)) return Option.none() + return Option.some(path.join(binDir, resolved.value)) + }).pipe( + Effect.scoped, + Effect.orElseSucceed(() => Option.none()), + ), ) }) @@ -261,14 +262,9 @@ export async function install(...args: Parameters) { } export async function add(...args: Parameters) { - const entry = await runPromise((svc) => svc.add(...args)) - return { - directory: entry.directory, - entrypoint: Option.getOrUndefined(entry.entrypoint), - } + return runPromise((svc) => svc.add(...args)) } export async function which(...args: Parameters) { - const resolved = await runPromise((svc) => svc.which(...args)) - return Option.getOrUndefined(resolved) + return runPromise((svc) => svc.which(...args)) } diff --git a/packages/core/src/permission/schema.ts b/packages/core/src/permission/schema.ts index 2d806dbd8c5..9fde8ef8ec1 100644 --- a/packages/core/src/permission/schema.ts +++ b/packages/core/src/permission/schema.ts @@ -12,5 +12,5 @@ export const Rule = Schema.Struct({ }).annotate({ identifier: "PermissionV2.Rule" }) export type Rule = typeof Rule.Type -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) +export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" }) export type Ruleset = typeof Ruleset.Type diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index aaef65d3227..c826ba513bf 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -7,6 +7,7 @@ import type { ModelV2 } from "./model" import type { Catalog } from "./catalog" import { EventV2 } from "./event" import { KeyedMutex } from "./effect/keyed-mutex" +import { State } from "./state" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type @@ -22,7 +23,7 @@ export const Event = { type HookSpec = { "catalog.transform": { - input: Catalog.Editor + input: Catalog.Draft output: {} } "aisdk.language": { @@ -62,18 +63,16 @@ export type HookFunctions = { export type HookInput = HookSpec[Name]["input"] export type HookOutput = HookSpec[Name]["output"] -export type Effect = Effect.Effect - -export function define(input: { id: ID; effect: Effect.Effect }) { - return input -} - export interface Interface { readonly add: (input: { - id: ID + id: string effect: Effect.Effect }) => Effect.Effect readonly remove: (id: ID) => Effect.Effect + readonly hook: ( + name: Name, + callback: (input: Hooks[Name]) => Effect.Effect | void, + ) => Effect.Effect readonly triggerFor: ( id: ID, name: Name, @@ -97,35 +96,40 @@ export const layer = Layer.effect( hooks: HookFunctions scope: Scope.Closeable }[] = [] + let registrations: { + [Name in keyof Hooks]: { + name: Name + callback: (input: Hooks[Name]) => Effect.Effect | void + } + }[keyof Hooks][] = [] const events = yield* EventV2.Service const scope = yield* Scope.Scope const locks = KeyedMutex.makeUnsafe() const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { - yield* locks.withLock(input.id)( + const id = ID.make(input.id) + yield* locks.withLock(id)( Effect.gen(function* () { - const existing = hooks.find((item) => item.id === input.id) + const existing = hooks.find((item) => item.id === id) if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) const childScope = yield* Scope.fork(scope) const result = yield* input.effect.pipe( Scope.provide(childScope), Effect.withSpan("Plugin.load", { attributes: { - "plugin.id": input.id, + "plugin.id": id, }, }), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)), ) - hooks = [ - ...hooks.filter((item) => item.id !== input.id), - { - id: input.id, - hooks: result ?? {}, - scope: childScope, - }, - ] - yield* events.publish(Event.Added, { id: input.id }) + const next = { + id, + hooks: result ?? {}, + scope: childScope, + } + hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next] + yield* events.publish(Event.Added, { id }) }), ) }), @@ -160,6 +164,12 @@ export const layer = Layer.effect( ) } + for (const item of registrations) { + if (item.name !== name) continue + const result = item.callback(event as never) + if (Effect.isEffect(result)) yield* result + } + for (const [field, draft] of draftEntries) { event[field] = finishDraft(draft) } @@ -175,6 +185,19 @@ export const layer = Layer.effect( }), ) }), + hook: Effect.fn("Plugin.hook")(function* (name, callback) { + const scope = yield* Scope.Scope + const registration = { name, callback } as (typeof registrations)[number] + let active = true + registrations = [...registrations, registration] + const dispose = Effect.sync(() => { + if (!active) return + active = false + registrations = registrations.filter((item) => item !== registration) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }), }) return svc }), diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index e8a8d8bc9d6..735ddd31072 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,12 +1,11 @@ export * as AgentPlugin from "./agent" import path from "path" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" -import { Location } from "../location" import { PermissionV2 } from "../permission" -import { PluginV2 } from "../plugin" const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") const BUILD_SYSTEM = @@ -97,12 +96,10 @@ Rules: - If the conversation ends with an unanswered question to the user, preserve that exact question - If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("agent"), - effect: Effect.gen(function* () { - const agent = yield* AgentV2.Service - const location = yield* Location.Service - const worktree = location.directory +export const Plugin = define({ + id: "agent", + effect: Effect.fn(function* (ctx) { + const worktree = ctx.location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const readonlyExternalDirectory: PermissionV2.Ruleset = [ { action: "external_directory", resource: "*", effect: "ask" }, @@ -122,8 +119,8 @@ export const Plugin = PluginV2.define({ { action: "read", resource: "*.env.example", effect: "allow" }, ] - yield* agent.update((editor) => { - editor.update(AgentV2.defaultID, (item) => { + yield* ctx.agent.transform((draft) => { + draft.update(AgentV2.defaultID, (item) => { item.description = "The default agent. Executes tools based on configured permissions." item.system ??= BUILD_SYSTEM item.mode = "primary" @@ -135,7 +132,7 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("plan"), (item) => { + draft.update(AgentV2.ID.make("plan"), (item) => { item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( @@ -154,14 +151,14 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("general"), (item) => { + draft.update(AgentV2.ID.make("general"), (item) => { item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("explore"), (item) => { + draft.update(AgentV2.ID.make("explore"), (item) => { item.description = 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' item.system = PROMPT_EXPLORE @@ -182,21 +179,21 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("compaction"), (item) => { + draft.update(AgentV2.ID.make("compaction"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("title"), (item) => { + draft.update(AgentV2.ID.make("title"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("summary"), (item) => { + draft.update(AgentV2.ID.make("summary"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index cc7b0c247a9..3acd94a218f 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,7 +1,7 @@ export * as PluginBoot from "./boot" +import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect" import { Context, Deferred, Effect, Layer } from "effect" -import { Credential } from "../credential" import { Integration } from "../integration" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" @@ -13,6 +13,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigReferencePlugin } from "../config/plugin/reference" import { EventV2 } from "../event" import { FSUtil } from "../fs-util" +import { FileSystem } from "../filesystem" import { Global } from "../global" import { Location } from "../location" import { ModelsDev } from "../models-dev" @@ -26,29 +27,13 @@ import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" import { Reference } from "../reference" +import { State } from "../state" +import { PluginHost } from "./host" -type Plugin = { - id: PluginV2.ID - effect: PluginV2.Effect< - | Catalog.Service - | CommandV2.Service - | Credential.Service - | Integration.Service - | AgentV2.Service - | Npm.Service - | EventV2.Service - | FSUtil.Service - | Global.Service - | Location.Service - | PluginV2.Service - | Config.Service - | ModelsDev.Service - | SkillV2.Service - | Reference.Service - > -} +type InternalPlugin = PublicPlugin export interface Interface { + readonly add: (plugin: PublicPlugin) => Effect.Effect readonly wait: () => Effect.Effect } @@ -60,8 +45,7 @@ export const layer = Layer.effect( const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const integrations = yield* Integration.Service + const integration = yield* Integration.Service const agents = yield* AgentV2.Service const config = yield* Config.Service const location = yield* Location.Service @@ -69,47 +53,54 @@ export const layer = Layer.effect( const npm = yield* Npm.Service const events = yield* EventV2.Service const fs = yield* FSUtil.Service + const filesystem = yield* FileSystem.Service const global = yield* Global.Service const skill = yield* SkillV2.Service - const references = yield* Reference.Service + const reference = yield* Reference.Service + const host = yield* PluginHost.make() const done = yield* Deferred.make() - const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { + const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) { yield* plugin.add({ id: input.id, - effect: input.effect.pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Credential.Service, credentials), - Effect.provideService(Integration.Service, integrations), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, references), - Effect.provideService(PluginV2.Service, plugin), - ), + effect: input + .effect(host) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), }) }) const boot = Effect.gen(function* () { - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - for (const item of ProviderPlugins) { - yield* add(item) - } - yield* add(ModelsDevPlugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) + yield* State.batch( + Effect.gen(function* () { + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigReferencePlugin.Plugin) + for (const item of ProviderPlugins) { + yield* add(item) + } + }), + ) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -119,12 +110,22 @@ export const layer = Layer.effect( ) return Service.of({ + add: (input) => + Deferred.await(done).pipe( + Effect.andThen( + plugin.add({ + id: input.id, + effect: input.effect(host), + }), + ), + ), wait: () => Deferred.await(done), }) }), ) export const locationLayer = layer.pipe( + Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Catalog.locationLayer), Layer.provideMerge(CommandV2.locationLayer), @@ -132,4 +133,5 @@ export const locationLayer = layer.pipe( Layer.provideMerge(AgentV2.locationLayer), Layer.provideMerge(SkillV2.locationLayer), Layer.provideMerge(Reference.locationLayer), + Layer.provideMerge(FileSystem.locationLayer), ) diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 66386a2128e..121bc0e6ccb 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,26 +1,20 @@ export * as CommandPlugin from "./command" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -import { CommandV2 } from "../command" -import { Location } from "../location" -import { PluginV2 } from "../plugin" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("command"), - effect: Effect.gen(function* () { - const command = yield* CommandV2.Service - const location = yield* Location.Service - const transform = yield* command.transform() - - yield* transform((editor) => { - editor.update("init", (command) => { - command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) +export const Plugin = define({ + id: "command", + effect: Effect.fn(function* (ctx) { + yield* ctx.command.transform((draft) => { + draft.update("init", (command) => { + command.template = PROMPT_INITIALIZE.replace("${path}", ctx.location.project.directory) command.description = "guided AGENTS.md setup" }) - editor.update("review", (command) => { - command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) + draft.update("review", (command) => { + command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts new file mode 100644 index 00000000000..833671c0a58 --- /dev/null +++ b/packages/core/src/plugin/host.ts @@ -0,0 +1,236 @@ +export * as PluginHost from "./host" + +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { PluginHost as Interface } from "@opencode-ai/plugin/v2/effect" +import type { Event as SDKEvent, ModelV2Info } from "@opencode-ai/sdk/v2/types" +import { Effect, Schema, Stream } from "effect" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { EventV2 } from "../event" +import { FileSystem } from "../filesystem" +import { Global } from "../global" +import { Integration } from "../integration" +import { Location } from "../location" +import { ModelV2 } from "../model" +import { Npm } from "../npm" +import { PluginV2 } from "../plugin" +import { ProviderV2 } from "../provider" +import { Reference } from "../reference" +import { SkillV2 } from "../skill" + +type EventMap = { [Item in SDKEvent as Item["type"]]: Item } +type SDKHook = (event: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any +}) => Effect.Effect | void +type LanguageHook = (event: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 +}) => Effect.Effect | void + +export const make = Effect.fn("PluginHost.make")(function* () { + const agents = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const events = yield* EventV2.Service + const filesystem = yield* FileSystem.Service + const global = yield* Global.Service + const integration = yield* Integration.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const plugin = yield* PluginV2.Service + const reference = yield* Reference.Service + const skill = yield* SkillV2.Service + + return { + agent: { + get: (id) => agents.get(AgentV2.ID.make(id)), + default: agents.default, + list: agents.all, + rebuild: agents.rebuild, + transform: (callback) => + agents.transform((draft) => + callback({ + list: draft.list, + get: (id) => draft.get(AgentV2.ID.make(id)), + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => draft.update(AgentV2.ID.make(id), update), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + }), + ), + }, + aisdk: { + hook: (name, callback) => { + if (name === "sdk") { + const run = callback as SDKHook + return plugin.hook("aisdk.sdk", (event) => { + const output = { + model: event.model, + package: event.package, + options: event.options, + sdk: event.sdk, + } + const result = run(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }) + } + const run = callback as LanguageHook + return plugin.hook("aisdk.language", (event) => { + const output = { + model: event.model, + sdk: event.sdk, + options: event.options, + language: event.language, + } + const result = run(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.language = output.language))), + ) + }) + }, + }, + catalog: { + provider: { + get: (id) => catalog.provider.get(ProviderV2.ID.make(id)), + list: catalog.provider.all, + available: catalog.provider.available, + }, + model: { + get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + list: catalog.model.all, + available: catalog.model.available, + default: catalog.model.default, + small: (providerID) => catalog.model.small(ProviderV2.ID.make(providerID)), + }, + rebuild: catalog.rebuild, + transform: (callback) => + catalog.transform((draft) => + callback({ + provider: { + list: draft.provider.list, + get: (id) => draft.provider.get(ProviderV2.ID.make(id)), + update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update), + remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), + }, + model: { + get: (providerID, modelID) => draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update), + remove: (providerID, modelID) => + draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: draft.model.default.get, + set: (providerID, modelID) => + draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + }), + ), + }, + command: { + get: commands.get, + list: commands.list, + rebuild: commands.rebuild, + transform: commands.transform, + }, + event: { + subscribe: (type: Type): Stream.Stream => + Stream.unwrap( + Effect.sync(() => { + const definition = EventV2.registry.get(type) + if (!definition) throw new Error(`Unknown event type: ${type}`) + const encode = Schema.encodeUnknownSync(definition.data as Schema.Codec) + return events.subscribe(definition).pipe( + Stream.map( + (event) => + ({ + id: event.id, + type: event.type, + properties: encode(event.data), + }) as unknown as EventMap[Type], + ), + ) + }), + ), + }, + filesystem: { + read: (input) => filesystem.read(Schema.decodeUnknownSync(FileSystem.ReadInput)(input)), + list: (input) => filesystem.list(Schema.decodeUnknownSync(FileSystem.ListInput)(input ?? {})), + find: (input) => filesystem.find(Schema.decodeUnknownSync(FileSystem.FindInput)(input)), + glob: (input) => filesystem.glob(Schema.decodeUnknownSync(FileSystem.GlobInput)(input)), + }, + integration: { + get: (id) => integration.get(Integration.ID.make(id)), + list: integration.list, + rebuild: integration.rebuild, + transform: (callback) => + integration.transform((draft) => + callback({ + list: draft.list, + get: (id) => draft.get(Integration.ID.make(id)), + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => draft.method.list(Integration.ID.make(id)), + update: (input) => { + if (input.method.type === "env") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "env", names: input.method.names }, + }) + return + } + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "key", label: input.method.label }, + }) + }, + remove: (id, method) => + draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), + }, + }), + ), + }, + location, + npm, + path: { + home: global.home, + data: global.data, + cache: global.cache, + config: global.config, + state: global.state, + temp: global.tmp, + }, + reference: { + list: reference.list, + rebuild: reference.rebuild, + transform: (callback) => + reference.transform((draft) => + callback({ + add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)), + remove: draft.remove, + list: draft.list, + }), + ), + }, + skill: { + sources: skill.sources, + list: skill.list, + rebuild: skill.rebuild, + transform: (callback) => + skill.transform((draft) => + callback({ + source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), + list: draft.list, + }), + ), + }, + } satisfies Interface +}) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index a212d013ad1..34f46685007 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,16 +1,13 @@ -import { DateTime, Effect, Scope, Stream } from "effect" -import { Catalog } from "../catalog" -import { Integration } from "../integration" -import { EventV2 } from "../event" +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect, Stream } from "effect" import { ModelV2 } from "../model" import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" -import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" function released(date: string) { const time = Date.parse(date) - return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0) + return Number.isFinite(time) ? time : 0 } function cost(input: ModelsDev.Model["cost"]) { @@ -51,22 +48,16 @@ function variants(model: ModelsDev.Model, packageName?: string) { }) } -export const ModelsDevPlugin = PluginV2.define({ - id: PluginV2.ID.make("models-dev"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service - const events = yield* EventV2.Service - const scope = yield* Scope.Scope - const transform = yield* catalog.transform() - const integrationTransform = yield* integrations.transform() - const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { - const data = yield* modelsDev.get() - yield* integrationTransform((integrations) => { + yield* ctx.integration.transform( + Effect.fn(function* (integrations) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { if (item.env.length === 0) continue - const integrationID = Integration.ID.make(item.id) + const integrationID = item.id integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({ integrationID, @@ -77,8 +68,11 @@ export const ModelsDevPlugin = PluginV2.define({ method: { type: "env", names: [...item.env] }, }) } - }) - yield* transform((catalog) => { + }), + ) + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { @@ -132,11 +126,10 @@ export const ModelsDevPlugin = PluginV2.define({ }) } } - }) - }) - yield* refresh() - yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach(() => refresh()), + }), + ) + yield* ctx.event.subscribe("models-dev.refreshed").pipe( + Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index fa5c0a91cfb..a75d0c0d08b 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const AlibabaPlugin = PluginV2.define({ - id: PluginV2.ID.make("alibaba"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const AlibabaPlugin = define({ + id: "alibaba", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) evt.sdk = mod.createAlibaba(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 9c7fd65665a..fe7bc10365b 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) { return sdk.responses(modelID) } -export const AmazonBedrockPlugin = PluginV2.define({ - id: PluginV2.ID.make("amazon-bedrock"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AmazonBedrockPlugin = define({ + id: "amazon-bedrock", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue @@ -77,7 +77,10 @@ export const AmazonBedrockPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE @@ -108,7 +111,10 @@ export const AmazonBedrockPlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) evt.sdk = mod.createAmazonBedrock(options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { evt.language = selectMantleModel(evt.sdk, evt.model.api.id) @@ -117,6 +123,6 @@ export const AmazonBedrockPlugin = PluginV2.define({ const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 9bd69fe036c..7c36d6dd9be 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const AnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AnthropicPlugin = define({ + id: "anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/anthropic") continue @@ -15,11 +15,14 @@ export const AnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) evt.sdk = mod.createAnthropic(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 173fd36621f..9115dcefe3c 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { return sdk.languageModel(modelID) } -export const AzurePlugin = PluginV2.define({ - id: PluginV2.ID.make("azure"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzurePlugin = define({ + id: "azure", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/azure") continue @@ -27,7 +27,10 @@ export const AzurePlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { if ( @@ -43,19 +46,22 @@ export const AzurePlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) evt.sdk = mod.createAzure(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) -export const AzureCognitiveServicesPlugin = PluginV2.define({ - id: PluginV2.ID.make("azure-cognitive-services"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzureCognitiveServicesPlugin = define({ + id: "azure-cognitive-services", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return for (const item of evt.provider.list()) { @@ -67,10 +73,13 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ }) } }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index f8719436873..f82f3eacc65 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,24 +1,27 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const CerebrasPlugin = PluginV2.define({ - id: PluginV2.ID.make("cerebras"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (ctx) { - for (const item of ctx.provider.list()) { +export const CerebrasPlugin = define({ + id: "cerebras", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/cerebras") continue - ctx.provider.update(item.provider.id, (provider) => { + evt.provider.update(item.provider.id, (provider) => { provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) evt.sdk = mod.createCerebras(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index ba7856b6357..d6ba76db60c 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,13 +1,14 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const CloudflareAIGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-ai-gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CloudflareAIGatewayPlugin = define({ + id: "cloudflare-ai-gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return @@ -31,7 +32,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({ }, } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 10f3f5200a9..3904ee5b833 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,16 +1,16 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") -export const CloudflareWorkersAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-workers-ai"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const CloudflareWorkersAIPlugin = define({ + id: "cloudflare-workers-ai", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { @@ -20,7 +20,10 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ if (accountId) provider.api.url = workersEndpoint(accountId) }) }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -34,11 +37,14 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ }) as any, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 991c370d175..df9f64685d0 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const CoherePlugin = PluginV2.define({ - id: PluginV2.ID.make("cohere"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CoherePlugin = define({ + id: "cohere", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) evt.sdk = mod.createCohere(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index bbd42f6e283..2f62029a57b 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const DeepInfraPlugin = PluginV2.define({ - id: PluginV2.ID.make("deepinfra"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const DeepInfraPlugin = define({ + id: "deepinfra", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) evt.sdk = mod.createDeepInfra(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index e5abc7009e3..4ab7c738da1 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,19 +1,18 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const DynamicProviderPlugin = PluginV2.define({ - id: PluginV2.ID.make("dynamic-provider"), - effect: Effect.gen(function* () { - const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const DynamicProviderPlugin = define({ + id: "dynamic-provider", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.sdk) return const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -26,6 +25,6 @@ export const DynamicProviderPlugin = PluginV2.define({ evt.sdk = mod[match](evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 5b08ad9ef5e..6e8f9186108 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const GatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GatewayPlugin = define({ + id: "gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) evt.sdk = mod.createGateway(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 1fc7c0c7999..6adc366c04f 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" function shouldUseResponses(modelID: string) { @@ -11,16 +11,31 @@ function shouldUseResponses(modelID: string) { return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") } -export const GithubCopilotPlugin = PluginV2.define({ - id: PluginV2.ID.make("github-copilot"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GithubCopilotPlugin = define({ + id: "github-copilot", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + const item = evt.provider.get(ProviderV2.ID.githubCopilot) + if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + }), + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { evt.language = evt.sdk.languageModel(evt.model.api.id) @@ -30,15 +45,6 @@ export const GithubCopilotPlugin = PluginV2.define({ ? evt.sdk.responses(evt.model.api.id) : evt.sdk.chat(evt.model.api.id) }), - "catalog.transform": Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.githubCopilot) - if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) - }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 9de090a95d6..70af0716463 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,14 +1,15 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" -export const GitLabPlugin = PluginV2.define({ - id: PluginV2.ID.make("gitlab"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GitLabPlugin = define({ + id: "gitlab", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) evt.sdk = mod.createGitLab({ @@ -30,7 +31,10 @@ export const GitLabPlugin = PluginV2.define({ }, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} @@ -58,6 +62,6 @@ export const GitLabPlugin = PluginV2.define({ featureFlags, }) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index a7168d59add..e3d42950473 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { } } -export const GoogleVertexPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexPlugin = define({ + id: "google-vertex", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if ( @@ -83,7 +83,10 @@ export const GoogleVertexPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) return @@ -100,19 +103,22 @@ export const GoogleVertexPlugin = PluginV2.define({ location, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) -export const GoogleVertexAnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex-anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexAnthropicPlugin = define({ + id: "google-vertex-anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue @@ -132,7 +138,10 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) const project = @@ -156,10 +165,13 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ : {}), }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 47e29c6b5d5..19b240b7016 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const GooglePlugin = PluginV2.define({ - id: PluginV2.ID.make("google"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GooglePlugin = define({ + id: "google", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) evt.sdk = mod.createGoogleGenerativeAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index f2052afd1a8..6a6e14ae6d6 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const GroqPlugin = PluginV2.define({ - id: PluginV2.ID.make("groq"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GroqPlugin = define({ + id: "groq", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) evt.sdk = mod.createGroq(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index e293a66dad1..f57322a9030 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const KiloPlugin = PluginV2.define({ - id: PluginV2.ID.make("kilo"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const KiloPlugin = define({ + id: "kilo", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -16,6 +16,6 @@ export const KiloPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 613f589ba5a..5c9802065bf 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,19 +1,17 @@ import { Effect } from "effect" -import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const LLMGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("llmgateway"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service - return { - "catalog.transform": Effect.fn(function* (evt) { +export const LLMGatewayPlugin = define({ + id: "llmgateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.disabled) continue - if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue + if (!(yield* ctx.integration.get(item.provider.id))) continue evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["X-Title"] = "opencode" @@ -21,6 +19,6 @@ export const LLMGatewayPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index e7f0decb79e..a799c2b451a 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const MistralPlugin = PluginV2.define({ - id: PluginV2.ID.make("mistral"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const MistralPlugin = define({ + id: "mistral", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) evt.sdk = mod.createMistral(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 837fce2c094..25f695d9526 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const NvidiaPlugin = PluginV2.define({ - id: PluginV2.ID.make("nvidia"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const NvidiaPlugin = define({ + id: "nvidia", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -17,6 +17,6 @@ export const NvidiaPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index 76c33737066..de2da085fe0 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,17 +1,18 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const OpenAICompatiblePlugin = PluginV2.define({ - id: PluginV2.ID.make("openai-compatible"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const OpenAICompatiblePlugin = define({ + id: "openai-compatible", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (evt.options.includeUsage !== false) evt.options.includeUsage = true const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible(evt.options as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d58bd784f52..07fb2fec975 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,29 +1,20 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" import { Integration } from "../../integration" import { browser, headless } from "./openai-auth" -export const OpenAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("openai"), - effect: Effect.gen(function* () { +export const OpenAIPlugin = define({ + id: "openai", + effect: Effect.fn(function* (ctx) { const integrations = yield* Integration.Service - yield* integrations.update((editor) => { - editor.method.update(browser) - editor.method.update(headless) + yield* integrations.transform((draft) => { + draft.method.update(browser) + draft.method.update(headless) }) - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/openai") return - const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) - evt.sdk = mod.createOpenAI(evt.options) - }), - "aisdk.language": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.openai) return - evt.language = evt.sdk.responses(evt.model.api.id) - }), - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai") continue @@ -35,6 +26,21 @@ export const OpenAIPlugin = PluginV2.define({ }) } }), - } + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/openai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) + evt.sdk = mod.createOpenAI(evt.options) + }), + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + evt.language = evt.sdk.responses(evt.model.api.id) + }), + ) }), }) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 56e71f822dd..1414d5a1ece 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,18 +1,16 @@ import { Effect } from "effect" -import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" -export const OpencodePlugin = PluginV2.define({ - id: PluginV2.ID.make("opencode"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service +export const OpencodePlugin = define({ + id: "opencode", + effect: Effect.fn(function* (ctx) { let hasKey = false - return { - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return - const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) + const integration = yield* ctx.integration.get(item.provider.id) hasKey = Boolean( process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, ) @@ -27,6 +25,6 @@ export const OpencodePlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index bc56a11b54d..81c4911d969 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const OpenRouterPlugin = PluginV2.define({ - id: PluginV2.ID.make("openrouter"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const OpenRouterPlugin = define({ + id: "openrouter", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue @@ -24,11 +24,14 @@ export const OpenRouterPlugin = PluginV2.define({ } } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) evt.sdk = mod.createOpenRouter(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 2415ab7c1a2..c9e1873deeb 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const PerplexityPlugin = PluginV2.define({ - id: PluginV2.ID.make("perplexity"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const PerplexityPlugin = define({ + id: "perplexity", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) evt.sdk = mod.createPerplexity(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 47c8b7eaa8c..b3675961bf2 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,15 +1,14 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" -export const SapAICorePlugin = PluginV2.define({ - id: PluginV2.ID.make("sap-ai-core"), - effect: Effect.gen(function* () { - const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const SapAICorePlugin = define({ + id: "sap-ai-core", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = process.env.AICORE_SERVICE_KEY ?? @@ -18,7 +17,7 @@ export const SapAICorePlugin = PluginV2.define({ const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -35,10 +34,13 @@ export const SapAICorePlugin = PluginV2.define({ : {}, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 0971f3518d1..48e5e73aad9 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -64,11 +64,12 @@ export function cortexFetch(upstream: FetchLike = fetch) { } } -export const SnowflakeCortexPlugin = PluginV2.define({ - id: PluginV2.ID.make("snowflake-cortex"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const SnowflakeCortexPlugin = define({ + id: "snowflake-cortex", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = process.env.SNOWFLAKE_CORTEX_TOKEN ?? @@ -84,6 +85,6 @@ export const SnowflakeCortexPlugin = PluginV2.define({ fetch: cortexFetch(upstream) as typeof fetch, } as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index b1870f26625..10eb849bafc 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const TogetherAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("togetherai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const TogetherAIPlugin = define({ + id: "togetherai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) evt.sdk = mod.createTogetherAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 8a3b950245c..2d2bc4dc91e 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const VenicePlugin = PluginV2.define({ - id: PluginV2.ID.make("venice"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const VenicePlugin = define({ + id: "venice", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) evt.sdk = mod.createVenice(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index a7e0bdf5a8c..45f117158d2 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const VercelPlugin = PluginV2.define({ - id: PluginV2.ID.make("vercel"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const VercelPlugin = define({ + id: "vercel", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/vercel") continue @@ -15,11 +15,14 @@ export const VercelPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) evt.sdk = mod.createVercel(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 4e9d53e47a5..5fc10e8675b 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,20 +1,24 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" -export const XAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("xai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const XAIPlugin = define({ + id: "xai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index a4f6a0ea01c..497561e00ed 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const ZenmuxPlugin = PluginV2.define({ - id: PluginV2.ID.make("zenmux"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const ZenmuxPlugin = define({ + id: "zenmux", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -16,6 +16,6 @@ export const ZenmuxPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 620fdc8b9ab..1dec8ba3570 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,22 +2,19 @@ export * as SkillPlugin from "./skill" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -import { PluginV2 } from "../plugin" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("skill"), - effect: Effect.gen(function* () { - const skill = yield* SkillV2.Service - const transform = yield* skill.transform() - - yield* transform((editor) => { - editor.source( +export const Plugin = define({ + id: "skill", + effect: Effect.fn(function* (ctx) { + yield* ctx.skill.transform((draft) => { + draft.source( new SkillV2.EmbeddedSource({ type: "embedded", skill: new SkillV2.Info({ diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 3f5424a47f3..f12fd2c0688 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,7 +1,7 @@ export * as ProviderV2 from "./provider" import { withStatics } from "./schema" -import { Schema } from "effect" +import { Schema, Types } from "effect" export const ID = Schema.String.pipe( Schema.brand("ProviderV2.ID"), @@ -37,6 +37,10 @@ export const Native = Schema.Struct({ export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) export type Api = typeof Api.Type +export type MutableApi = T extends Api + ? Omit, "settings"> & + (undefined extends T["settings"] ? { settings?: any } : { settings: any }) + : never export const Request = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.String), @@ -66,3 +70,5 @@ export class Info extends Schema.Class("ProviderV2.Info")({ }) } } + +export type MutableInfo = Omit, "api"> & { api: MutableApi } diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 66eb160eb49..5ed46d76e13 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -1,7 +1,6 @@ export * as Reference from "./reference" -import { Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft } from "immer" +import { Context, Effect, Layer, Schema, Scope, Types } from "effect" import { Global } from "./global" import { EventV2 } from "./event" import { Repository } from "./repository" @@ -40,17 +39,16 @@ export class Info extends Schema.Class("Reference.Info")({ }) {} type Data = { - sources: Map + sources: Map> } -type Editor = { +type Draft = { add(name: string, source: Source): void remove(name: string): void list(): readonly [string, Source][] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly list: () => Effect.Effect } @@ -64,18 +62,18 @@ export const layer = Layer.effect( const cache = yield* RepositoryCache.Service const scope = yield* Scope.Scope const materialized = new Map() - const state = State.create({ + const state = State.create({ initial: () => ({ sources: new Map() }), - editor: (draft) => ({ - add: (name, source) => draft.sources.set(name, castDraft(source)), + draft: (draft) => ({ + add: (name, source) => draft.sources.set(name, source as Types.DeepMutable), remove: (name) => draft.sources.delete(name), list: () => Array.from(draft.sources.entries()) as [string, Source][], }), - finalize: (editor) => + finalize: (draft) => Effect.gen(function* () { materialized.clear() const seen = new Map() - for (const [name, source] of editor.list()) { + for (const [name, source] of draft.list()) { if (source.type === "local") { materialized.set( name, @@ -128,6 +126,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, + rebuild: state.rebuild, list: Effect.fn("Reference.list")(function* () { return Array.from(materialized.values()) }), diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 3d93a899978..d4e617ebf42 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -33,11 +33,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass Effect.Effect @@ -149,10 +145,12 @@ export const locationLayer = Layer.effect( resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. yield* boot.wait() + const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model ? yield* catalog.model.get(session.model.providerID, session.model.id) - : (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ?? - (yield* catalog.model.available()).find(supported)) + : defaultModel && supported(defaultModel) + ? defaultModel + : (yield* catalog.model.available()).find(supported) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) return yield* fromCatalogModel( diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 259c8aff5e5..158fb4fab5e 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -1,8 +1,7 @@ export * as SkillV2 from "./skill" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" -import { castDraft } from "immer" +import { Context, Effect, Layer, Schema, Types } from "effect" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" import { FSUtil } from "./fs-util" @@ -65,16 +64,15 @@ const Frontmatter = Schema.Struct({ const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) export type Data = { - sources: Source[] + sources: Types.DeepMutable[] } -export type Editor = { +export type Draft = { source: (source: Source) => void list: () => readonly Source[] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly sources: () => Effect.Effect readonly list: () => Effect.Effect } @@ -87,12 +85,12 @@ export const layer = Layer.effect( const discovery = yield* SkillDiscovery.Service const fs = yield* FSUtil.Service - const state = State.create({ + const state = State.create({ initial: () => ({ sources: [] }), - editor: (draft) => ({ + draft: (draft) => ({ source: (source) => { if (draft.sources.some((item) => Source.equals(item, source))) return - draft.sources.push(castDraft(source)) + draft.sources.push(source as Types.DeepMutable) }, list: () => draft.sources as Source[], }), @@ -150,6 +148,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, + rebuild: state.rebuild, sources: Effect.fn("SkillV2.sources")(function* () { return state.get().sources }), diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 7f1ae58d24c..1c540e0e97c 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -1,112 +1,128 @@ export * as State from "./state" -import { Effect, Scope, Semaphore } from "effect" -import type { Draft, Objectish } from "immer" +import { Context, Effect, Scope, Semaphore } from "effect" /** - * A replayable transform applied to an editor during rebuild. + * A replayable transform applied to a draft during rebuild. * - * Transforms are intentionally synchronous and mutation-shaped: domain editors - * hide the draft representation while preserving concise plugin/config code. + * Domain drafts expose readable and writable state while preserving concise + * plugin/config code. Transforms may perform Effects before returning. */ -export type Transform = (editor: Editor) => void -export type MakeEditor = (draft: Draft) => Editor +type TransformCallback = (draft: DraftApi) => Effect.Effect | void +export type MakeDraft = (state: State) => DraftApi -export interface Options { +export interface Registration { + readonly dispose: Effect.Effect +} + +export type Transform = ( + transform: TransformCallback, +) => Effect.Effect + +export type Rebuild = () => Effect.Effect + +export interface Transformable { + readonly transform: Transform + readonly rebuild: Rebuild +} + +const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { + defaultValue: () => undefined, +}) + +export function batch(effect: Effect.Effect) { + return Effect.gen(function* () { + const current = yield* CurrentBatch + if (current) return yield* effect + const rebuilds = new Set() + const result = yield* effect.pipe(Effect.provideService(CurrentBatch, rebuilds)) + yield* Effect.forEach(rebuilds, (rebuild) => rebuild(), { discard: true }) + return result + }) +} + +export interface Options { /** Creates the base value for initial state and every scoped-transform rebuild. */ readonly initial: () => State - /** Wraps the mutable draft in a domain-specific editor. */ - readonly editor: MakeEditor - /** - * Completes every committed edit. - * - * For rebuilds, this runs after all active transforms have been replayed and - * before the rebuilt state becomes visible. For direct updates, this runs - * after the current state has already been edited. The optional reason is - * caller-defined metadata for exceptional update origins. - */ - readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect + /** Wraps mutable state in a domain-specific draft API. */ + readonly draft: MakeDraft + /** Runs after all active transforms and before the rebuilt state becomes visible. */ + readonly finalize?: (draft: DraftApi) => Effect.Effect } -export interface Interface { +export interface Interface extends Transformable { readonly get: () => State /** - * Registers a scoped transform slot and returns the slot updater. - * - * Acquiring the slot has no visible effect until the returned updater is - * called. Each updater call replaces that slot's transform, then rebuilds the - * materialized state from `initial()` by replaying all active transforms in - * registration order. Closing the owning Scope removes the slot and rebuilds. + * Registers and applies a scoped transform. Closing the owning Scope removes + * the transform and rebuilds the materialized state. */ - readonly transform: () => Effect.Effect<(transform: Transform) => Effect.Effect, never, Scope.Scope> - /** Registers and applies a replayable transform in the current Scope. */ - readonly update: (update: Transform) => Effect.Effect - /** - * Mutates the current materialized state directly, once. - * - * This is not replayable transform state: a later rebuild starts again - * from `initial()` plus active transforms, so direct edits must be reserved - * for current-state adjustments that are intentionally outside the transform - * fold. - */ - readonly mutate: (update: (editor: Editor) => Effect.Effect, reason?: string) => Effect.Effect } -export function create(options: Options): Interface { +export function create(options: Options): Interface { let state = options.initial() - let transforms: { update: Transform }[] = [] + let transforms: { run: TransformCallback }[] = [] const semaphore = Semaphore.makeUnsafe(1) - const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) { - const api = options.editor(next as Draft) - if (options.finalize) yield* options.finalize(api, reason) + const commit = Effect.fn("State.commit")(function* (next: State) { + const api = options.draft(next) + if (options.finalize) yield* options.finalize(api) state = next }) - const rebuild = Effect.fnUntraced(function* () { + const apply = (transform: TransformCallback, draft: DraftApi) => + Effect.suspend(() => { + const result = transform(draft) + return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void + }) + + const materialize = Effect.fnUntraced(function* () { const next = options.initial() - const api = options.editor(next as Draft) - for (const transform of transforms) - yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {})) + const api = options.draft(next) + for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update")) yield* commit(next) }) - const result: Interface = { + const rebuild = () => semaphore.withPermit(materialize()) + + const result: Interface = { get: () => state, - transform: Effect.fn("State.transform")(function* () { + transform: Effect.fn("State.transform")(function* (update) { const scope = yield* Scope.Scope return yield* Effect.uninterruptible( Effect.gen(function* () { - const transform = { update: (_editor: Editor) => {} } - transforms = [...transforms, transform] - yield* Scope.addFinalizer( - scope, + const transform = { run: update } + let active = true + const dispose = Effect.uninterruptible( semaphore.withPermit( - Effect.sync(() => { + Effect.suspend(() => { + if (!active) return Effect.void + active = false transforms = transforms.filter((item) => item !== transform) - }).pipe(Effect.andThen(rebuild())), + return Effect.gen(function* () { + const batch = yield* CurrentBatch + if (batch) { + batch.add(rebuild) + return + } + yield* materialize() + }) + }), ), ) - return (update: Transform) => - Effect.uninterruptible( - semaphore.withPermit( - Effect.sync(() => { - transform.update = update - }).pipe(Effect.andThen(rebuild())), - ), - ) + yield* semaphore.withPermit( + Effect.sync(() => { + transforms = [...transforms, transform] + }), + ) + yield* Scope.addFinalizer(scope, dispose) + const batch = yield* CurrentBatch + if (batch) batch.add(rebuild) + else yield* rebuild() + return { dispose } }), ) }), - update: Effect.fn("State.update")(function* (update) { - const transform = yield* result.transform() - yield* transform(update) - }), - mutate: Effect.fn("State.mutate")(function* (update, reason) { - const api = options.editor(state as Draft) - yield* update(api) - if (options.finalize) yield* options.finalize(api, reason) - }, semaphore.withPermit), + rebuild, } return result } diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 024c2006d04..5309e4b4ba7 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -1,7 +1,6 @@ export * as ApplicationTools from "./application-tools" import { Context, Effect, Layer, Scope } from "effect" -import { enableMapSet } from "immer" import { State } from "../state" import { Tool } from "./tool" @@ -9,7 +8,7 @@ type Data = { readonly entries: Map } -type Editor = { +type Draft = { readonly set: (name: string, entry: Entry) => void } @@ -27,14 +26,12 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ApplicationTools") {} -enableMapSet() - export const layer = Layer.effect( Service, Effect.gen(function* () { - const state = State.create({ + const state = State.create({ initial: () => ({ entries: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ set: (name, tool) => { draft.entries.set(name, tool) }, @@ -47,9 +44,8 @@ export const layer = Layer.effect( if (entries.length === 0) return yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) - const transform = yield* state.transform() - yield* transform((editor) => { - for (const [name, entry] of registrations) editor.set(name, entry) + yield* state.transform((draft) => { + for (const [name, entry] of registrations) draft.set(name, entry) }) }), entries: () => state.get().entries, diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 9f46eca4e96..f8b8d4eb2ed 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -6,6 +6,7 @@ import { AgentPlugin } from "@opencode-ai/core/plugin/agent" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" +import { agentHost, host } from "./plugin/host" const it = testEffect(AgentV2.locationLayer) @@ -23,9 +24,7 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("reviewer") - const transform = yield* agent.transform() - - yield* transform((editor) => + yield* agent.transform((editor) => editor.update(id, (info) => { info.description = "Reviews code" info.mode = "subagent" @@ -41,19 +40,17 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("reviewer") - const transform = yield* agent.transform() - - yield* transform((editor) => + let description = "Old description" + let hidden = true + yield* agent.transform((editor) => editor.update(id, (info) => { - info.description = "Old description" - info.hidden = true - }), - ) - yield* transform((editor) => - editor.update(id, (info) => { - info.description = "New description" + info.description = description + info.hidden = hidden }), ) + description = "New description" + hidden = false + yield* agent.rebuild() expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) }), @@ -64,9 +61,7 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("scoped") const scope = yield* Scope.make() - const transform = yield* agent.transform().pipe(Scope.provide(scope)) - - yield* transform((editor) => editor.update(id, () => {})) + yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope)) expect(yield* agent.get(id)).toBeDefined() yield* Scope.close(scope, Exit.void) @@ -79,7 +74,7 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("build") - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(id, (info) => { info.mode = "primary" info.hidden = true @@ -95,10 +90,10 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("custom") - yield* agent.update((editor) => editor.update(id, () => {})) + yield* agent.transform((editor) => editor.update(id, () => {})) expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id)) - yield* agent.update((editor) => editor.remove(id)) + yield* agent.transform((editor) => editor.remove(id)) expect(yield* agent.get(id)).toBeUndefined() }), ) @@ -106,11 +101,11 @@ describe("AgentV2", () => { it.effect("does not ambiently opt built-in agents into bash", () => Effect.gen(function* () { const agent = yield* AgentV2.Service - yield* AgentPlugin.Plugin.effect.pipe( - Effect.provideService( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("/project") })), - ), + yield* AgentPlugin.Plugin.effect( + host({ + agent: agentHost(agent), + location: location({ directory: AbsolutePath.make("/project") }), + }), ) const agents = yield* agent.all() diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 77a18e79aef..cc1051bc2c9 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,18 +1,17 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" +import { Effect, Fiber, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" import { Policy } from "@opencode-ai/core/policy" -import { Project } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" +import { required } from "./plugin/provider-helper" const locationLayer = Layer.succeed( Location.Service, @@ -41,7 +40,7 @@ describe("CatalogV2", () => { .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) expect((yield* Fiber.join(updated)).length).toBe(1) }), @@ -76,14 +75,13 @@ describe("CatalogV2", () => { return Effect.gen(function* () { const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) - expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) active = second expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) - expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) }).pipe(Effect.provide(layer)) }) @@ -99,13 +97,13 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service const providerID = ProviderV2.ID.make("test") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID: Integration.ID.make(providerID), method: { type: "env", names: ["CATALOG_TEST_API_KEY"] }, }), ) - yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {})) + yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) }), @@ -121,9 +119,7 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -134,7 +130,7 @@ describe("CatalogV2", () => { }), ) - expect((yield* catalog.provider.get(providerID)).api).toEqual({ + expect(required(yield* catalog.provider.get(providerID)).api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://override.example.com", @@ -147,9 +143,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -168,7 +162,7 @@ describe("CatalogV2", () => { }) }) - expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({ + expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ id: modelID, type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -183,9 +177,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -196,7 +188,7 @@ describe("CatalogV2", () => { catalog.model.update(providerID, modelID, () => {}) }) - expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({ + expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ id: modelID, type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -205,106 +197,12 @@ describe("CatalogV2", () => { }), ) - it.effect("runs catalog transform hooks after baseURL is normalized", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const providerID = ProviderV2.ID.make("test") - const seen: unknown[] = [] - const transform = yield* catalog.transform() - - yield* plugin.add({ - id: PluginV2.ID.make("test"), - effect: Effect.succeed({ - "catalog.transform": (evt) => - Effect.sync(() => { - const item = evt.provider.get(providerID) - if (!item) return - seen.push(item.provider.api.type) - if (item?.provider.api.type === "aisdk") seen.push(item.provider.api.url) - seen.push(item?.provider.request.body.baseURL) - }), - }), - }) - yield* transform((catalog) => - catalog.provider.update(providerID, (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } - provider.request.body.baseURL = "https://provider.example.com" - }), - ) - - expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined]) - }), - ) - - it.effect("runs catalog transform when a plugin is added", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => - catalog.provider.update(providerID, (provider) => { - provider.name = "Before" - }), - ) - yield* plugin.add({ - id: PluginV2.ID.make("test-transform"), - effect: Effect.succeed({ - "catalog.transform": (evt) => - Effect.sync(() => - evt.provider.update(providerID, (provider) => { - provider.name = "After" - }), - ), - }), - }) - yield* Effect.yieldNow - - expect((yield* catalog.provider.get(providerID)).name).toBe("After") - }), - ) - - it.effect("ignores plugin additions from another location", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const plugin = yield* PluginV2.Service - let invoked = 0 - - yield* plugin.add({ - id: PluginV2.ID.make("test-transform"), - effect: Effect.succeed({ - "catalog.transform": () => Effect.sync(() => invoked++), - }), - }) - yield* Effect.yieldNow - expect(invoked).toBe(1) - - yield* events.publish( - PluginV2.Event.Added, - { id: PluginV2.ID.make("test-transform") }, - { - location: new Location.Info({ - directory: AbsolutePath.make("other"), - project: { id: Project.ID.global, directory: AbsolutePath.make("other") }, - }), - }, - ) - yield* Effect.yieldNow - - expect(invoked).toBe(1) - }), - ) - it.effect("resolves provider and model request merges", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.request.headers.provider = "provider" provider.request.headers.shared = "provider" @@ -321,7 +219,7 @@ describe("CatalogV2", () => { }) }) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) expect(model.request.body).toEqual({ provider: true, model: true, request: true }) expect(model.request.options).toEqual({ shared: "model", model: true }) @@ -332,19 +230,17 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { - model.time.released = DateTime.makeUnsafe(1000) + model.time.released = 1000 }) catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { - model.time.released = DateTime.makeUnsafe(2000) + model.time.released = 2000 }) }) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new") + expect((yield* catalog.model.default())?.id).toMatch("new") }), ) @@ -354,26 +250,26 @@ describe("CatalogV2", () => { const providerID = ProviderV2.ID.make("test") const old = ModelV2.ID.make("old") const newest = ModelV2.ID.make("new") - const transform = yield* catalog.transform() - - const models = (catalog: Catalog.Editor) => { + const models = (catalog: Catalog.Draft) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, old, (model) => { - model.time.released = DateTime.makeUnsafe(1000) + model.time.released = 1000 }) catalog.model.update(providerID, newest, (model) => { - model.time.released = DateTime.makeUnsafe(2000) + model.time.released = 2000 }) } - yield* transform((catalog) => { + let configured = true + yield* catalog.transform((catalog) => { models(catalog) - catalog.model.default.set(providerID, old) + if (configured) catalog.model.default.set(providerID, old) }) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old) + expect((yield* catalog.model.default())?.id).toBe(old) - yield* transform(models) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest) + configured = false + yield* catalog.rebuild() + expect((yield* catalog.model.default())?.id).toBe(newest) }), ) @@ -384,9 +280,7 @@ describe("CatalogV2", () => { const enabledProvider = ProviderV2.ID.make("enabled") const disabledModel = ModelV2.ID.make("configured") const fallbackModel = ModelV2.ID.make("fallback") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(disabledProvider, (provider) => { provider.disabled = true }) @@ -396,7 +290,7 @@ describe("CatalogV2", () => { catalog.model.default.set(disabledProvider, disabledModel) }) - expect(Option.getOrUndefined(yield* catalog.model.default())).toMatchObject({ + expect(yield* catalog.model.default()).toMatchObject({ providerID: enabledProvider, id: fallbackModel, }) @@ -407,25 +301,23 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) }) - expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") + expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) @@ -434,17 +326,15 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const policy = yield* Policy.Service const providerID = ProviderV2.ID.make("blocked") - const transform = yield* catalog.transform() - yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) }) expect(yield* catalog.provider.all()).toEqual([]) expect(yield* catalog.model.all()).toEqual([]) - expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none()) + expect(yield* catalog.provider.get(providerID)).toBeUndefined() }), ) }) diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index f2175743e42..8c2c5843fef 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -11,8 +11,7 @@ describe("CommandV2", () => { it.effect("applies command transforms and preserves later overrides", () => Effect.gen(function* () { const command = yield* CommandV2.Service - const transform = yield* command.transform() - yield* transform((editor) => { + yield* command.transform((editor) => { editor.update("review", (command) => { command.template = "First" command.description = "Review code" diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 79e872f74e2..ea553671bda 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -10,6 +10,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { agentHost, host } from "../plugin/host" const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer)) const decode = Schema.decodeUnknownSync(Config.Info) @@ -19,9 +20,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") - const defaults = yield* agents.transform() - - yield* defaults((editor) => + yield* agents.transform((editor) => editor.update(build, (agent) => { agent.mode = "primary" agent.permissions.push({ action: "bash", resource: "*", effect: "allow" }) @@ -68,9 +67,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) const buildAgent = yield* agents.get(build) @@ -150,9 +148,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) @@ -177,8 +174,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") - const defaults = yield* agents.transform() - yield* defaults((editor) => editor.update(build, () => {})) + yield* agents.transform((editor) => editor.update(build, () => {})) const config = Config.Service.of({ entries: () => @@ -190,9 +186,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) expect(yield* agents.get(build)).toBeUndefined() @@ -251,9 +246,8 @@ Use native v2 fields.`, ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index da3bb749b45..bc84d9cdb58 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -11,6 +11,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer)) const decode = Schema.decodeUnknownSync(Config.Info) @@ -41,8 +42,7 @@ Review files`, }) const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), + yield* ConfigCommandPlugin.Plugin.effect(host({ command })).pipe( Effect.provideService( Config.Service, Config.Service.of({ diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index a2ecc9954b3..1a6ba447ae8 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -7,7 +7,8 @@ import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, withEnv } from "../plugin/provider-helper" +import { it, required, withEnv } from "../plugin/provider-helper" +import { catalogHost, host, integrationHost } from "../plugin/host" function request(headers: Record, variant?: string) { return { @@ -58,14 +59,14 @@ describe("ConfigProviderPlugin.Plugin", () => { yield* plugin.add({ ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( + effect: ConfigProviderPlugin.Plugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), ), }) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants).toMatchObject([ { id: "high", @@ -119,14 +120,14 @@ describe("ConfigProviderPlugin.Plugin", () => { yield* plugin.add({ ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( + effect: ConfigProviderPlugin.Plugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), ), }) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants[0]).toMatchObject({ id: "high", body: {}, @@ -222,16 +223,16 @@ describe("ConfigProviderPlugin.Plugin", () => { yield* plugin.add({ ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( + effect: ConfigProviderPlugin.Plugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), ), }) - const provider = yield* catalog.provider.get(providerID) - const model = yield* catalog.model.get(providerID, modelID) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) + const provider = required(yield* catalog.provider.get(providerID)) + const model = required(yield* catalog.model.get(providerID, modelID)) + expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) expect(provider.name).toBe("Renamed") expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({ type: "env", diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 52b9c0bb666..2f86714bb2a 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -9,6 +9,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" const it = testEffect(Layer.empty) const decode = Schema.decodeUnknownSync(Config.Info) @@ -18,16 +19,33 @@ describe("ConfigSkillPlugin.Plugin", () => { Effect.gen(function* () { const directory = AbsolutePath.make("/repo/packages/app") const sources: SkillV2.Source[] = [] - const transform = Effect.fnUntraced(function* () { - return Effect.fnUntraced(function* (update: (editor: SkillV2.Editor) => void) { - update({ - source: (source) => sources.push(source), - list: () => sources, - }) + const transform = Effect.fnUntraced(function* (update: (draft: SkillV2.Draft) => void | Effect.Effect) { + const result = update({ + source: (source) => { + sources.push(source) + }, + list: () => sources, }) + if (Effect.isEffect(result)) yield* result + const dispose = Effect.sync(() => { + sources.length = 0 + }) + yield* Effect.addFinalizer(() => dispose) + return { dispose } }) - yield* ConfigSkillPlugin.Plugin.effect.pipe( + yield* ConfigSkillPlugin.Plugin.effect( + host({ + location: location({ directory }), + path: { ...host().path, home: "/home/test" }, + skill: SkillV2.Service.of({ + transform, + rebuild: () => Effect.void, + sources: () => Effect.succeed(sources), + list: () => Effect.succeed([]), + }), + }), + ).pipe( Effect.provideService( Config.Service, Config.Service.of({ @@ -43,16 +61,6 @@ describe("ConfigSkillPlugin.Plugin", () => { ]), }), ), - Effect.provideService(Global.Service, Global.Service.of(Global.make({ home: "/home/test" }))), - Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), - Effect.provideService( - SkillV2.Service, - SkillV2.Service.of({ - transform, - sources: () => Effect.succeed(sources), - list: () => Effect.succeed([]), - }), - ), ) expect(sources).toEqual([ diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index ca4362c6058..ac9cd33e8d1 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -51,7 +51,7 @@ describe("Integration", () => { const openai = Integration.ID.make("openai") yield* integrations - .update((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) + .transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(scope)) expect(yield* integrations.get(openai)).toEqual( new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }), @@ -70,10 +70,10 @@ describe("Integration", () => { const second = yield* Scope.fork(yield* Scope.Scope) yield* integrations - .update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI"))) + .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(first)) yield* integrations - .update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override"))) + .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override"))) .pipe(Scope.provide(second)) expect((yield* integrations.get(id))?.name).toBe("OpenAI Override") @@ -99,7 +99,7 @@ describe("Integration", () => { }) yield* integrations - .update((editor) => + .transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -108,7 +108,7 @@ describe("Integration", () => { ) .pipe(Scope.provide(first)) yield* integrations - .update((editor) => { + .transform((editor) => { expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" }) expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }]) expect(editor.method.list(integrationID)).toEqual([ @@ -141,7 +141,7 @@ describe("Integration", () => { const integrations = yield* Integration.Service const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { type: "key", label: "API key" }, @@ -179,7 +179,7 @@ describe("Integration", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -238,7 +238,7 @@ describe("Integration", () => { const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") let closed = false - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -275,7 +275,7 @@ describe("Integration", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -312,7 +312,7 @@ describe("Integration", () => { const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") let closed = false - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -375,7 +375,7 @@ describe("Integration", () => { () => Effect.gen(function* () { const integrations = yield* Integration.Service - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 69dba2ae0a5..9e75bbb641c 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,8 +1,10 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Equal, Hash, Layer, Schema } from "effect" +import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" import { Tool } from "@opencode-ai/core/public" +import { define } from "@opencode-ai/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" @@ -86,8 +88,7 @@ describe("LocationServiceMap", () => { yield* PluginBoot.Service.use((boot) => boot.wait()) yield* Reference.Service const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) return { providers: yield* catalog.provider.all(), tools: yield* toolDefinitions(yield* ToolRegistry.Service), @@ -135,4 +136,53 @@ describe("LocationServiceMap", () => { ), ), ) + + it.live("installs public plugins into a location", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const boot = yield* PluginBoot.Service + const catalogUpdated = yield* Deferred.make() + const seen: string[] = [] + yield* boot.add( + define({ + id: "reviewer", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => Deferred.succeed(catalogUpdated, undefined).pipe(Effect.asVoid)), + Effect.forkScoped({ startImmediately: true }), + ) + yield* ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + seen.push((yield* ctx.agent.get("reviewer"))?.description ?? "") + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update("public", (provider) => { + provider.name = "Public provider" + }) + }) + }), + }), + ) + + yield* Deferred.await(catalogUpdated) + expect(seen).toEqual(["Reviews code"]) + expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }).pipe( + Effect.scoped, + Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), + ), + ), + ), + ) }) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index c149116cd5e..349d9ab7e48 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -60,7 +60,7 @@ describe("Npm.add", () => { return yield* npm.add(spec) }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) - expect(Option.isSome(entry.entrypoint)).toBe(true) + expect(entry.entrypoint).toBeDefined() }) }) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index ebe06400e98..2120a9f51ad 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -74,8 +74,7 @@ function setup(rules: PermissionV2.Ruleset = []) { function setRules(rules: PermissionV2.Ruleset) { return Effect.gen(function* () { const agents = yield* AgentV2.Service - const update = yield* agents.transform() - yield* update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("test"), (agent) => { agent.permissions = [...rules] }), @@ -130,7 +129,7 @@ describe("PermissionV2", () => { Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "read", resource: "*", effect: "deny" }) }), @@ -139,7 +138,7 @@ describe("PermissionV2", () => { expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" }) expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "deny" }) - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions = [] }), @@ -187,8 +186,7 @@ describe("PermissionV2", () => { .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service - const update = yield* agents.transform() - yield* update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }] }), @@ -214,7 +212,7 @@ describe("PermissionV2", () => { .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service - yield* agents.update((editor) => { + yield* agents.transform((editor) => { editor.remove(AgentV2.ID.make("test")) editor.remove(AgentV2.ID.make("build")) }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index d89d531147c..69b63683c76 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -18,7 +18,7 @@ const plugins = PluginV2.layer.pipe(Layer.provide(events)) function state() { return State.create({ initial: () => ({ values: [] as string[] }), - editor: (draft) => ({ + draft: (draft) => ({ add: (value: string) => draft.values.push(value), }), }) @@ -34,8 +34,9 @@ describe("PluginV2", () => { yield* plugin.add({ id: PluginV2.ID.make("scoped"), effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("scoped")) + yield* values.transform((editor) => { + editor.add("scoped") + }) }), }) expect(values.get().values).toEqual(["scoped"]) @@ -58,8 +59,9 @@ describe("PluginV2", () => { .add({ id, effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("first")) + yield* values.transform((editor) => { + editor.add("first") + }) yield* Deferred.succeed(firstStarted, undefined) yield* Deferred.await(releaseFirst) }), @@ -71,8 +73,9 @@ describe("PluginV2", () => { .add({ id, effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("second")) + yield* values.transform((editor) => { + editor.add("second") + }) }), }) .pipe(Effect.forkChild({ startImmediately: true })) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 099e1825185..d9d68e98b18 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -6,6 +6,7 @@ import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { host } from "./host" const directory = AbsolutePath.make("/repo/packages/app") const project = AbsolutePath.make("/repo") @@ -21,12 +22,11 @@ describe("CommandPlugin.Plugin", () => { it.effect("registers built-in init and review commands", () => Effect.gen(function* () { const command = yield* CommandV2.Service - yield* CommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), - Effect.provideService( - Location.Service, - Location.Service.of(location({ directory }, { projectDirectory: project })), - ), + yield* CommandPlugin.Plugin.effect( + host({ + command, + location: location({ directory }, { projectDirectory: project }), + }), ) expect(yield* command.get("init")).toMatchObject({ diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts new file mode 100644 index 00000000000..17ebd7bda32 --- /dev/null +++ b/packages/core/test/plugin/host.ts @@ -0,0 +1,317 @@ +import type { AISDKHooks, PluginHost } from "@opencode-ai/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" +import { Effect, Stream } from "effect" + +export function host(overrides: Partial = {}): PluginHost { + return { + aisdk: { + hook: () => Effect.die("unused aisdk.hook"), + }, + agent: { + get: () => Effect.die("unused agent.get"), + default: () => Effect.die("unused agent.default"), + list: () => Effect.die("unused agent.list"), + rebuild: () => Effect.die("unused agent.rebuild"), + transform: () => Effect.die("unused agent.transform"), + }, + catalog: { + provider: { + get: () => Effect.die("unused catalog.provider.get"), + list: () => Effect.die("unused catalog.provider.list"), + available: () => Effect.die("unused catalog.provider.available"), + }, + model: { + get: () => Effect.die("unused catalog.model.get"), + list: () => Effect.die("unused catalog.model.list"), + available: () => Effect.die("unused catalog.model.available"), + default: () => Effect.die("unused catalog.model.default"), + small: () => Effect.die("unused catalog.model.small"), + }, + rebuild: () => Effect.die("unused catalog.rebuild"), + transform: () => Effect.die("unused catalog.transform"), + }, + command: { + get: () => Effect.die("unused command.get"), + list: () => Effect.die("unused command.list"), + rebuild: () => Effect.die("unused command.rebuild"), + transform: () => Effect.die("unused command.transform"), + }, + event: { + subscribe: () => Stream.die("unused event.subscribe"), + }, + filesystem: { + read: () => Effect.die("unused filesystem.read"), + list: () => Effect.die("unused filesystem.list"), + find: () => Effect.die("unused filesystem.find"), + glob: () => Effect.die("unused filesystem.glob"), + }, + integration: { + get: () => Effect.die("unused integration.get"), + list: () => Effect.die("unused integration.list"), + rebuild: () => Effect.die("unused integration.rebuild"), + transform: () => Effect.die("unused integration.transform"), + }, + location: { + directory: "/unused/location", + project: { directory: "/unused/project" }, + }, + npm: { + add: () => Effect.die("unused npm.add"), + }, + path: { + home: "/unused/home", + data: "/unused/data", + cache: "/unused/cache", + config: "/unused/config", + state: "/unused/state", + temp: "/unused/temp", + }, + reference: { + list: () => Effect.die("unused reference.list"), + rebuild: () => Effect.die("unused reference.rebuild"), + transform: () => Effect.die("unused reference.transform"), + }, + skill: { + sources: () => Effect.die("unused skill.sources"), + list: () => Effect.die("unused skill.list"), + rebuild: () => Effect.die("unused skill.rebuild"), + transform: () => Effect.die("unused skill.transform"), + }, + ...overrides, + } +} + +export function aisdkHost(plugin: PluginV2.Interface): PluginHost["aisdk"] { + return { + hook: (name, callback) => { + if (name === "sdk") { + const run = callback as AISDKHooks["sdk"] + return plugin.hook("aisdk.sdk", (event) => { + const output = { ...event } + const result = run(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }) + } + const run = callback as AISDKHooks["language"] + return plugin.hook("aisdk.language", (event) => { + const output = { ...event } + const result = run(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.language = output.language))), + ) + }) + }, + } +} + +export function agentHost(agent: AgentV2.Interface): PluginHost["agent"] { + return { + ...host().agent, + transform: (callback) => + agent.transform((draft) => + callback({ + list: () => draft.list().map(agentInfo), + get: (id) => { + const value = draft.get(AgentV2.ID.make(id)) + return value && agentInfo(value) + }, + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => + draft.update(AgentV2.ID.make(id), (value) => { + const current = agentInfo(value) + update(current) + Object.assign(value, current, { id: AgentV2.ID.make(current.id) }) + }), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + }), + ), + } +} + +export function catalogHost(catalog: Catalog.Interface): PluginHost["catalog"] { + return { + ...host().catalog, + rebuild: catalog.rebuild, + transform: (callback) => + catalog.transform((draft) => + callback({ + provider: { + list: () => + draft.provider.list().map((value) => ({ + provider: providerInfo(value.provider), + models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])), + })), + get: (id) => { + const value = draft.provider.get(ProviderV2.ID.make(id)) + return ( + value && { + provider: providerInfo(value.provider), + models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])), + } + ) + }, + update: (id, update) => + draft.provider.update(ProviderV2.ID.make(id), (value) => { + const current = providerInfo(value) + update(current) + Object.assign(value, current, { id: ProviderV2.ID.make(current.id) }) + }), + remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), + }, + model: { + get: (providerID, modelID) => { + const value = draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)) + return value && modelInfo(value) + }, + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), (value) => { + const current = modelInfo(value) + update(current) + Object.assign(value, current, { + id: ModelV2.ID.make(current.id), + providerID: ProviderV2.ID.make(current.providerID), + family: current.family === undefined ? undefined : ModelV2.Family.make(current.family), + variants: current.variants.map((variant) => ({ + ...variant, + id: ModelV2.VariantID.make(variant.id), + })), + }) + }), + remove: (providerID, modelID) => + draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: () => { + const value = draft.model.default.get() + return value && { providerID: value.providerID, modelID: value.modelID } + }, + set: (providerID, modelID) => + draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + }), + ), + } +} + +export function integrationHost(integration: Integration.Interface): PluginHost["integration"] { + const info = (value: Integration.Info) => ({ + id: value.id, + name: value.name, + methods: value.methods.map(method), + connections: value.connections.map((item) => ({ ...item })), + }) + return { + get: (id) => integration.get(Integration.ID.make(id)).pipe(Effect.map((value) => value && info(value))), + list: () => integration.list().pipe(Effect.map((items) => items.map(info))), + rebuild: integration.rebuild, + transform: (callback) => + integration.transform((draft) => + callback({ + list: () => draft.list().map((value) => ({ id: value.id, name: value.name })), + get: (id) => { + const value = draft.get(Integration.ID.make(id)) + return value && { id: value.id, name: value.name } + }, + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => draft.method.list(Integration.ID.make(id)).map(method), + update: (input) => + input.method.type === "env" + ? draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, names: [...input.method.names] }, + }) + : draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: input.method, + }), + remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), + }, + }), + ), + } +} + +function method(value: Integration.Method) { + if (value.type === "env") return { type: value.type, names: [...value.names] } + if (value.type === "key") return { type: value.type, label: value.label } + return { + type: value.type, + id: value.id, + label: value.label, + prompts: value.prompts?.map((prompt) => { + if (prompt.type === "text") return { ...prompt } + return { ...prompt, options: prompt.options.map((option) => ({ ...option })) } + }), + } +} + +function internalMethod(value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod): Integration.Method { + if (value.type === "env") return value + if (value.type === "key") return value + return { + ...value, + id: Integration.MethodID.make(value.id), + } +} + +function agentInfo(value: AgentV2.Info) { + return { + ...value, + model: value.model && { ...value.model }, + request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + permissions: value.permissions.map((permission) => ({ ...permission })), + } +} + +function providerInfo(value: ProviderV2.MutableInfo) { + return { + ...value, + api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + } +} + +function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) { + return { + ...value, + api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + capabilities: { + ...value.capabilities, + input: [...value.capabilities.input], + output: [...value.capabilities.output], + }, + request: { + ...value.request, + headers: { ...value.request.headers }, + body: { ...value.request.body }, + generation: value.request.generation && { + ...value.request.generation, + stop: value.request.generation.stop && [...value.request.generation.stop], + }, + options: value.request.options && { ...value.request.options }, + }, + variants: value.variants.map((variant) => ({ + ...variant, + headers: { ...variant.headers }, + body: { ...variant.body }, + generation: variant.generation && { + ...variant.generation, + stop: variant.generation.stop && [...variant.generation.stop], + }, + options: variant.options && { ...variant.options }, + })), + time: { ...value.time }, + cost: value.cost.map((cost) => ({ ...cost, tier: cost.tier && { ...cost.tier }, cache: { ...cost.cache } })), + limit: { ...value.limit }, + } +} diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 236985dac1c..6b3e153c3ce 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,6 +1,6 @@ import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" @@ -15,6 +15,7 @@ import { Policy } from "@opencode-ai/core/policy" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { catalogHost, host, integrationHost } from "./host" const events = EventV2.defaultLayer const locationLayer = Layer.succeed( @@ -56,8 +57,15 @@ describe("ModelsDevPlugin", () => { }), () => Effect.gen(function* () { - yield* ModelsDevPlugin.effect const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + event: { subscribe: () => Stream.never }, + integration: integrationHost(integrations), + }), + ) expect(yield* integrations.list()).toEqual([ new Integration.Info({ id: Integration.ID.make("acme"), diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index e2fbb8061a3..017f60fff30 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -4,13 +4,13 @@ import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) + yield* addPlugin(plugin, AlibabaPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, @@ -23,7 +23,7 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) + yield* addPlugin(plugin, AlibabaPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, @@ -36,7 +36,7 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) + yield* addPlugin(plugin, AlibabaPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -56,7 +56,7 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) + yield* addPlugin(plugin, AlibabaPlugin) const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } }) const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) const language = result.sdk?.languageModel(item.api.id) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index e1ae5bd6793..aadefcb5c03 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) @@ -30,9 +30,8 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AmazonBedrockPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* catalog.transform((catalog) => { const bedrock = provider("amazon-bedrock", { api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, request: { @@ -45,7 +44,7 @@ describe("AmazonBedrockPlugin", () => { item.request = bedrock.request }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock) + const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) expect(result.api).toEqual({ type: "aisdk", package: "@ai-sdk/amazon-bedrock", @@ -59,7 +58,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -84,7 +83,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -118,7 +117,7 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -138,7 +137,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -157,7 +156,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -176,7 +175,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -196,7 +195,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -225,7 +224,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -253,7 +252,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -282,7 +281,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) yield* plugin.trigger( "aisdk.language", { @@ -312,7 +311,7 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -341,7 +340,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -372,7 +371,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) yield* plugin.trigger( "aisdk.language", { @@ -433,7 +432,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) yield* plugin.trigger( "aisdk.language", { @@ -518,7 +517,7 @@ describe("AmazonBedrockPlugin", () => { expected: "au.anthropic.claude-sonnet-4-5", }, ] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) for (const item of cases) { yield* plugin.trigger( "aisdk.language", @@ -538,7 +537,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 85881c3e844..9b496817e7b 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -4,16 +4,15 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "./provider-helper" +import { addPlugin, it, model, provider, required } from "./provider-helper" describe("AnthropicPlugin", () => { it.effect("applies legacy beta headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AnthropicPlugin) + yield* catalog.transform((catalog) => { const item = provider("anthropic", { api: { type: "aisdk", package: "@ai-sdk/anthropic" }, request: { headers: { Existing: "1" }, body: {} }, @@ -23,10 +22,10 @@ describe("AnthropicPlugin", () => { draft.request = item.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( + expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", ) - expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1") + expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1") }), ) @@ -34,10 +33,9 @@ describe("AnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"]).toBeUndefined() + yield* addPlugin(plugin, AnthropicPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"]).toBeUndefined() }), ) @@ -45,7 +43,7 @@ describe("AnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(AnthropicPlugin) + yield* addPlugin(plugin, AnthropicPlugin) yield* plugin.add({ id: PluginV2.ID.make("anthropic-sdk-inspector"), effect: Effect.succeed({ @@ -72,7 +70,7 @@ describe("AnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(AnthropicPlugin) + yield* addPlugin(plugin, AnthropicPlugin) yield* plugin.add({ id: PluginV2.ID.make("anthropic-sdk-inspector"), effect: Effect.succeed({ diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 3101052cf9a..6d9139c7336 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" describe("AzureCognitiveServicesPlugin", () => { it.effect("maps the resource env var to the Azure SDK baseURL", () => @@ -12,14 +12,13 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzureCognitiveServicesPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")) + const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) expect(result.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -36,9 +35,8 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzureCognitiveServicesPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* catalog.transform((catalog) => { const azure = provider("azure-cognitive-services", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) @@ -50,8 +48,8 @@ describe("AzureCognitiveServicesPlugin", () => { item.api = openai.api }) }) - const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")) - const openai = yield* catalog.provider.get(ProviderV2.ID.openai) + const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) + const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) expect(azure.request.body.baseURL).toBeUndefined() expect(azure.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" }) expect(openai.request.body.baseURL).toBeUndefined() @@ -64,7 +62,7 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzureCognitiveServicesPlugin) + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* plugin.trigger( "aisdk.language", { @@ -82,7 +80,7 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzureCognitiveServicesPlugin) + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* plugin.trigger( "aisdk.language", { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, @@ -103,7 +101,7 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(AzureCognitiveServicesPlugin) + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index c4bdd806c9d..baa6d4f7394 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,35 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" - -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - Layer.provideMerge(npmLayer), - ), -) +import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" describe("AzurePlugin", () => { it.effect("resolves resourceName from env", () => @@ -37,14 +12,13 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.api = { type: "aisdk", package: "@ai-sdk/azure" } }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin(plugin, AzurePlugin) + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -54,9 +28,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const azure = provider("azure", { api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "from-config" } }, @@ -67,50 +39,19 @@ describe("AzurePlugin", () => { }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() + yield* addPlugin(plugin, AzurePlugin) + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() }), ), ) - itWithAccount.effect("prefers account resourceName over env", () => - withEnv( - { - AZURE_RESOURCE_NAME: "from-env", - }, - () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("azure"), - value: new Credential.Key({ - type: "key", - key: "key", - metadata: { resourceName: "from-account" }, - }), - }) - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - catalog.provider.update(ProviderV2.ID.azure, (item) => { - item.api = { type: "aisdk", package: "@ai-sdk/azure" } - }) - }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account") - }), - ), - ) - it.effect("falls back to env when configured resourceName is blank", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const azure = provider("azure", { api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "" } }, @@ -120,7 +61,8 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin(plugin, AzurePlugin) + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -130,9 +72,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const azure = provider("azure", { api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: " " } }, @@ -142,7 +82,8 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin(plugin, AzurePlugin) + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -151,7 +92,7 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -170,7 +111,7 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) const exit = yield* plugin .trigger( "aisdk.sdk", @@ -187,7 +128,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, @@ -201,7 +142,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, @@ -215,7 +156,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { @@ -235,7 +176,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, @@ -259,7 +200,7 @@ describe("AzurePlugin", () => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index aa192274d61..5bcb9f7a0b6 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model } from "./provider-helper" +import { addPlugin, it, model, required } from "./provider-helper" const cerebrasOptions: Record[] = [] @@ -23,15 +23,14 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CerebrasPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, CerebrasPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.request.headers.Existing = "1" }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode", }) @@ -42,10 +41,9 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CerebrasPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({}) + yield* addPlugin(plugin, CerebrasPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({}) }), ) @@ -53,7 +51,7 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) + yield* addPlugin(plugin, CerebrasPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -72,7 +70,7 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) + yield* addPlugin(plugin, CerebrasPlugin) yield* plugin.trigger( "aisdk.sdk", { @@ -90,7 +88,7 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) + yield* addPlugin(plugin, CerebrasPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 72ad5da33f1..2332a3ca27d 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -2,7 +2,7 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" -import { it, model, withEnv } from "./provider-helper" +import { addPlugin, it, model, withEnv } from "./provider-helper" const aiGatewayCalls: Record[] = [] const unifiedCalls: string[] = [] @@ -78,7 +78,7 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -98,7 +98,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -142,7 +142,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -171,7 +171,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -208,7 +208,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -239,7 +239,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -261,7 +261,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -284,7 +284,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -313,7 +313,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -336,7 +336,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -364,7 +364,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 208ab8710d3..8e27781d07a 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,36 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Location } from "@opencode-ai/core/location" -import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" - -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - Layer.provideMerge(npmLayer), - ), -) +import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -57,14 +32,13 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) const sdk = yield* plugin.trigger( "aisdk.sdk", { @@ -89,14 +63,13 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", url: "https://proxy.example/v1", @@ -109,7 +82,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -126,56 +99,19 @@ describe("CloudflareWorkersAIPlugin", () => { ), ) - itWithAccount.effect("falls back to account metadata when account env is absent", () => - withEnv( - { - CLOUDFLARE_ACCOUNT_ID: undefined, - CLOUDFLARE_API_KEY: undefined, - }, - () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("cloudflare-workers-ai"), - value: new Credential.Key({ - type: "key", - key: "account-key", - metadata: { accountId: "account-acct" }, - }), - }) - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => - catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { - provider.api = { type: "aisdk", package: "test-provider" } - }), - ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject( - { - apiKey: "account-key", - accountId: "account-acct", - }, - ) - }), - ), - ) - it.effect("uses env account ID over configured account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } provider.request.body.accountId = "configured-acct" }), ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", @@ -188,7 +124,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -217,7 +153,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -247,7 +183,7 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.language", { @@ -266,7 +202,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index a646c3eb6ce..c653f65a014 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" const cohereOptions: Record[] = [] @@ -24,7 +24,7 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CoherePlugin) + yield* addPlugin(plugin, CoherePlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", @@ -45,7 +45,7 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CoherePlugin) + yield* addPlugin(plugin, CoherePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -70,7 +70,7 @@ describe("CoherePlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(CoherePlugin) + yield* addPlugin(plugin, CoherePlugin) const result = yield* plugin.trigger( "aisdk.language", { model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} }, diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 43db117a908..7e2b6322f19 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -5,7 +5,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { PluginV2 } from "@opencode-ai/core/plugin" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" import { testEffect } from "../lib/effect" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" const itAISDK = testEffect( Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), @@ -36,7 +36,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, @@ -50,7 +50,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -69,7 +69,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -88,7 +88,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const packages = [ "unmatched-package", "@ai-sdk/deepinfra-compatible", @@ -119,7 +119,7 @@ describe("DeepInfraPlugin", () => { resetDeepInfraMock() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const language = yield* aisdk.language( model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { api: { type: "aisdk", package: "@ai-sdk/deepinfra" }, diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 2b0be314ba9..f3b0bf898f7 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -11,6 +11,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" import { testEffect } from "../lib/effect" +import { host } from "./host" import { fixtureProvider, it, model, npmLayer } from "./provider-helper" const fixtureProviderPath = fileURLToPath(fixtureProvider) @@ -18,19 +19,24 @@ const itWithAISDK = testEffect( AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), ) -function npmEntrypointLayer(entrypoint: Option.Option) { +function npmEntrypointLayer(entrypoint?: string) { return Layer.succeed( Npm.Service, Npm.Service.of({ add: () => Effect.succeed({ directory: "", entrypoint }), install: () => Effect.void, - which: () => Effect.succeed(Option.none()), + which: () => Effect.succeed(undefined), }), ) } function dynamicPlugin(layer = npmLayer) { - return { id: DynamicProviderPlugin.id, effect: DynamicProviderPlugin.effect.pipe(Effect.provide(layer)) } + return { + id: DynamicProviderPlugin.id, + effect: Effect.gen(function* () { + yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service })) + }).pipe(Effect.provide(layer)), + } } function tempEntrypoint(source: string) { @@ -102,7 +108,7 @@ describe("DynamicProviderPlugin", () => { it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(fixtureProviderPath)))) + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath))) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -120,7 +126,7 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.none()))) + yield* plugin.add(dynamicPlugin(npmEntrypointLayer())) const exit = yield* aisdk .language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) .pipe(Effect.exit) @@ -149,7 +155,7 @@ describe("DynamicProviderPlugin", () => { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(tmp.entrypoint)))) + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint))) const exit = yield* aisdk .language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) .pipe(Effect.exit) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 8ee69b7dd49..6627d185a58 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -2,7 +2,7 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" const gatewayCalls: Record[] = [] const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] @@ -27,7 +27,7 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + yield* addPlugin(plugin, GatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, @@ -42,7 +42,7 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + yield* addPlugin(plugin, GatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -63,7 +63,7 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + yield* addPlugin(plugin, GatewayPlugin) for (const modelID of vercelGatewayModels) { const ignored = yield* plugin.trigger( diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index f16b177e698..bbc41646a33 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -5,13 +5,13 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, required } from "./provider-helper" describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", { @@ -39,7 +39,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) yield* plugin.trigger( "aisdk.language", { @@ -57,7 +57,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) yield* plugin.trigger( "aisdk.language", { @@ -75,7 +75,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) yield* plugin.trigger( "aisdk.language", { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, @@ -115,7 +115,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) yield* plugin.trigger( "aisdk.language", { @@ -151,14 +151,13 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GithubCopilotPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, GithubCopilotPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect( - (yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, ).toBe(false) }), ) @@ -167,14 +166,13 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GithubCopilotPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, GithubCopilotPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect( - (yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, ).toBe(true) }), ) @@ -183,7 +181,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) const result = yield* plugin.trigger( "aisdk.language", { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index dab52a1f7fa..b4277d140fb 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,26 +1,12 @@ import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { it, model, npmLayer, withEnv } from "./provider-helper" +import { addPlugin, it, model, required, withEnv } from "./provider-helper" const gitlabSDKOptions: Record[] = [] -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", @@ -35,17 +21,6 @@ void mock.module("gitlab-ai-provider", () => ({ isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", })) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))), - ), - Layer.provideMerge(npmLayer), - ), -) - describe("GitLabPlugin", () => { it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => withEnv( @@ -57,7 +32,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.sdk", { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, @@ -90,7 +65,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.sdk", { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, @@ -111,7 +86,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.sdk", { @@ -152,7 +127,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, @@ -163,83 +138,11 @@ describe("GitLabPlugin", () => { }), ) - itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () => - withEnv( - { - GITLAB_TOKEN: "env-token", - }, - () => - Effect.gen(function* () { - gitlabSDKOptions.length = 0 - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("gitlab"), - value: new Credential.Key({ type: "key", key: "account-token" }), - }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: provider.request.body, - }, - {}, - ) - expect(gitlabSDKOptions[0].apiKey).toBe("account-token") - }), - ), - ) - - itWithAccount.effect("uses active account OAuth access token when no API token exists", () => - withEnv( - { - GITLAB_TOKEN: undefined, - }, - () => - Effect.gen(function* () { - gitlabSDKOptions.length = 0 - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("gitlab"), - value: new Credential.OAuth({ - type: "oauth", - methodID: Integration.MethodID.make("oauth"), - refresh: "refresh-token", - access: "account-oauth-token", - expires: 9999999999999, - }), - }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: provider.request.body, - }, - {}, - ) - expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token") - }), - ), - ) - it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) const result = yield* plugin.trigger( "aisdk.language", { @@ -275,7 +178,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) const result = yield* plugin.trigger( "aisdk.language", { @@ -302,7 +205,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.language", { @@ -331,7 +234,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index bdb6029487c..57c90e8148f 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" describe("GoogleVertexAnthropicPlugin", () => { it.effect("resolves legacy project and location env on provider update", () => @@ -21,14 +21,13 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) expect(provider.request.body.project).toBe("cloud-project") expect(provider.request.body.location).toBe("cloud-location") }), @@ -40,16 +39,15 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.request.body.project = "configured-project" provider.request.body.location = "configured-location" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) expect(provider.request.body.project).toBe("configured-project") expect(provider.request.body.location).toBe("configured-location") }), @@ -69,7 +67,7 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -92,7 +90,7 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -112,7 +110,7 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -131,7 +129,7 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -148,8 +146,8 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const sdkResult = yield* plugin.trigger( "aisdk.sdk", { @@ -180,7 +178,7 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* plugin.trigger( "aisdk.language", { @@ -198,7 +196,7 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index cb23cc452cd..bebfa1dc85b 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" const vertexOptions: Record[] = [] const googleAuthOptions: Record[] = [] @@ -39,9 +39,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.opencode, (provider) => { provider.api = { type: "aisdk", @@ -51,7 +50,7 @@ describe("GoogleVertexPlugin", () => { }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.opencode) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) expect(provider.request.body).toEqual({}) }), ) @@ -70,9 +69,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -81,7 +79,7 @@ describe("GoogleVertexPlugin", () => { } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("google-cloud-project") expect(provider.request.body.location).toBe("google-vertex-location") expect(provider.api).toEqual({ @@ -109,9 +107,8 @@ describe("GoogleVertexPlugin", () => { vertexOptions.length = 0 const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -120,7 +117,7 @@ describe("GoogleVertexPlugin", () => { } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) yield* plugin.trigger( "aisdk.sdk", { @@ -159,9 +156,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -172,7 +168,7 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "global" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("global") expect(provider.api).toEqual({ @@ -188,9 +184,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -201,7 +196,7 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "eu" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -224,15 +219,14 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.request.body.project = "config-project" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("us-central1") }), @@ -249,7 +243,7 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) + yield* addPlugin(plugin, GoogleVertexPlugin) yield* plugin.trigger( "aisdk.sdk", { @@ -274,7 +268,7 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) + yield* addPlugin(plugin, GoogleVertexPlugin) yield* plugin.add({ id: PluginV2.ID.make("capture-openai-compatible"), effect: Effect.succeed({ @@ -328,7 +322,7 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexPlugin) + yield* addPlugin(plugin, GoogleVertexPlugin) yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index 9880ff3ae58..c1fab4201e8 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -6,7 +6,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" import { testEffect } from "../lib/effect" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" const itWithAISDK = testEffect( AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), @@ -16,7 +16,7 @@ describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GooglePlugin) + yield* addPlugin(plugin, GooglePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -34,7 +34,7 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GooglePlugin) + yield* addPlugin(plugin, GooglePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, @@ -48,7 +48,7 @@ describe("GooglePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(GooglePlugin) + yield* addPlugin(plugin, GooglePlugin) const language = yield* aisdk.language( model("custom-google", "alias", { api: { diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index c6db66b1cb6..71eb1eeabdf 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -6,7 +6,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" import { testEffect } from "../lib/effect" const aisdkIt = testEffect( @@ -17,7 +17,7 @@ describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, @@ -30,7 +30,7 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, @@ -43,7 +43,7 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, @@ -56,7 +56,7 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -79,7 +79,7 @@ describe("GroqPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* aisdk.language( model("groq", "alias", { api: { diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index c15928435bc..1d04d750561 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -1,4 +1,5 @@ import { Npm } from "@opencode-ai/core/npm" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { LanguageModelV3 } from "@ai-sdk/provider" import { expect } from "bun:test" import { Effect, Layer, Option } from "effect" @@ -13,8 +14,15 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { aisdkHost, catalogHost, host, integrationHost } from "./host" export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href + +export function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") })), @@ -23,16 +31,17 @@ const locationLayer = Layer.succeed( export const npmLayer = Layer.succeed( Npm.Service, Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: Option.none() }), + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), install: () => Effect.void, - which: () => Effect.succeed(Option.none()), + which: () => Effect.succeed(undefined), }), ) export const catalogLayer = Layer.succeed( Catalog.Service, Catalog.Service.of({ - transform: () => Effect.die("unexpected catalog.transform"), + transform: (_transform) => Effect.die("unexpected catalog.transform"), + rebuild: () => Effect.die("unexpected catalog.rebuild"), provider: { get: () => Effect.die("unexpected provider.get"), all: () => Effect.succeed([]), @@ -42,8 +51,8 @@ export const catalogLayer = Layer.succeed( get: () => Effect.die("unexpected model.get"), all: () => Effect.succeed([]), available: () => Effect.succeed([]), - default: () => Effect.succeed(Option.none()), - small: () => Effect.succeed(Option.none()), + default: () => Effect.succeed(undefined), + small: () => Effect.succeed(undefined), }, }), ) @@ -70,9 +79,30 @@ export const it = testEffect( Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer), Layer.provideMerge(npmLayer), + Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), ), ) +export function addPlugin(plugin: PluginV2.Interface, definition: Plugin) { + return Effect.gen(function* () { + const catalog = yield* Effect.serviceOption(Catalog.Service) + const integration = yield* Effect.serviceOption(Integration.Service) + const npm = yield* Effect.serviceOption(Npm.Service) + const effect = + typeof definition.effect === "function" + ? definition.effect( + host({ + aisdk: aisdkHost(plugin), + ...(Option.isSome(catalog) ? { catalog: catalogHost(catalog.value) } : {}), + ...(Option.isSome(integration) ? { integration: integrationHost(integration.value) } : {}), + ...(Option.isSome(npm) ? { npm: npm.value } : {}), + }), + ) + : definition.effect + yield* plugin.add({ id: definition.id, effect }) + }) +} + type ProviderInput = Partial> & { api?: ProviderV2.Api request?: ProviderV2.Request diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 33da03c0327..d54bf31342b 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -5,7 +5,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => @@ -21,9 +21,8 @@ describe("KiloPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, KiloPlugin) + yield* catalog.transform((catalog) => { const kilo = provider("kilo", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, request: { headers: { Existing: "value" }, body: {} }, @@ -34,12 +33,12 @@ describe("KiloPlugin", () => { }) catalog.provider.update(provider("openrouter").id, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) }), ) @@ -47,9 +46,8 @@ describe("KiloPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, KiloPlugin) + yield* catalog.transform((catalog) => { const item = provider("kilo", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) @@ -58,7 +56,7 @@ describe("KiloPlugin", () => { }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) + const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))) expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -73,9 +71,8 @@ describe("KiloPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, KiloPlugin) + yield* catalog.transform((catalog) => { const kilo = provider("kilo", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) @@ -90,11 +87,11 @@ describe("KiloPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 39a643e348a..456880c194d 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -6,14 +6,18 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { expectPluginRegistered, it, provider, required } from "./provider-helper" +import { catalogHost, host, integrationHost } from "./host" describe("LLMGatewayPlugin", () => { const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service yield* plugin.add({ ...LLMGatewayPlugin, - effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + effect: LLMGatewayPlugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ), }) }) @@ -30,14 +34,12 @@ describe("LLMGatewayPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin) const integrations = yield* Integration.Service - yield* integrations.update((editor) => { + yield* integrations.transform((editor) => { editor.update(Integration.ID.make("llmgateway"), () => {}) editor.update(Integration.ID.make("openrouter"), () => {}) }) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const llmgateway = provider("llmgateway", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, request: { headers: { Existing: "value" }, body: {} }, @@ -48,13 +50,14 @@ describe("LLMGatewayPlugin", () => { }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ + yield* add(plugin) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-Source": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) }), ) @@ -63,8 +66,7 @@ describe("LLMGatewayPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* add(plugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const item = provider("llmgateway", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, }) @@ -73,8 +75,8 @@ describe("LLMGatewayPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index b442d4f4d6c..ea3b3a67096 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -3,13 +3,13 @@ import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, @@ -22,7 +22,7 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -40,7 +40,7 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) yield* plugin.add({ id: PluginV2.ID.make("mistral-sdk-inspector"), effect: Effect.succeed({ @@ -64,7 +64,7 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) yield* plugin.add({ id: PluginV2.ID.make("mistral-sdk-inspector"), effect: Effect.succeed({ @@ -92,7 +92,7 @@ describe("MistralPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) const result = yield* plugin.trigger( "aisdk.language", { model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} }, diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index e4f781e54a3..c5c986f6291 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -5,7 +5,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => @@ -21,9 +21,8 @@ describe("NvidiaPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, NvidiaPlugin) + yield* catalog.transform((catalog) => { const nvidia = provider("nvidia", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, request: { headers: { Existing: "value" }, body: {} }, @@ -34,13 +33,13 @@ describe("NvidiaPlugin", () => { }) catalog.provider.update(provider("openrouter").id, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) }), ) @@ -48,9 +47,8 @@ describe("NvidiaPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, NvidiaPlugin) + yield* catalog.transform((catalog) => { const item = provider("nvidia", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, request: { headers: {}, body: {} }, @@ -61,7 +59,7 @@ describe("NvidiaPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -73,9 +71,8 @@ describe("NvidiaPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, NvidiaPlugin) + yield* catalog.transform((catalog) => { const item = provider("nvidia", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, request: { @@ -89,7 +86,7 @@ describe("NvidiaPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index e8bf1f7575f..7e695c89c06 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -2,13 +2,13 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAICompatiblePlugin) + yield* addPlugin(plugin, OpenAICompatiblePlugin) const defaulted = yield* plugin.trigger( "aisdk.sdk", { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, @@ -31,7 +31,7 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAICompatiblePlugin) + yield* addPlugin(plugin, OpenAICompatiblePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -49,7 +49,7 @@ describe("OpenAICompatiblePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* plugin.add(OpenAICompatiblePlugin) + yield* addPlugin(plugin, OpenAICompatiblePlugin) yield* plugin.add({ id: PluginV2.ID.make("inspector"), effect: Effect.succeed({ @@ -85,7 +85,7 @@ describe("OpenAICompatiblePlugin", () => { }), }), }) - yield* plugin.add(OpenAICompatiblePlugin) + yield* addPlugin(plugin, OpenAICompatiblePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d30b585b961..7a50bbf145b 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -6,12 +6,15 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider } from "./provider-helper" +import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper" +import { host, integrationHost } from "./host" function add(plugin: PluginV2.Interface, integrations: Integration.Interface) { return plugin.add({ - ...OpenAIPlugin, - effect: OpenAIPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + id: OpenAIPlugin.id, + effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe( + Effect.provideService(Integration.Service, integrations), + ), }) } @@ -106,8 +109,7 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* add(plugin, yield* Integration.Service) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } }) catalog.provider.update(item.id, (draft) => { draft.api = item.api @@ -115,8 +117,8 @@ describe("OpenAIPlugin", () => { catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false) }), ) @@ -125,14 +127,13 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* add(plugin, yield* Integration.Service) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const item = provider("custom-openai") catalog.provider.update(item.id, () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect( - (yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 01cabf35811..5fb1b3b36aa 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Option } from "effect" +import { Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" @@ -11,7 +11,8 @@ import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" -import { it, model, provider, withEnv } from "./provider-helper" +import { it, model, provider, required, withEnv } from "./provider-helper" +import { catalogHost, host, integrationHost } from "./host" const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] const locationLayer = Layer.succeed( @@ -19,9 +20,11 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) -const pluginWithIntegrations = (integrations: Integration.Interface) => ({ +const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({ ...OpencodePlugin, - effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + effect: OpencodePlugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ), }) describe("OpencodePlugin", () => { @@ -30,9 +33,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -40,8 +42,8 @@ describe("OpencodePlugin", () => { draft.cost = [...paid.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) }), ), ) @@ -51,9 +53,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const free = model("opencode", "free", { cost: cost(0) }) @@ -61,8 +62,8 @@ describe("OpencodePlugin", () => { draft.cost = [...free.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) }), ), ) @@ -72,9 +73,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) @@ -82,8 +82,8 @@ describe("OpencodePlugin", () => { draft.cost = [...outputOnly.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true) + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true) }), ), ) @@ -93,9 +93,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -103,8 +102,8 @@ describe("OpencodePlugin", () => { draft.cost = [...paid.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -115,15 +114,14 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* plugin.add(pluginWithIntegrations(integrations)) - yield* integrations.update((editor) => { + yield* plugin.add(pluginWithIntegrations(catalog, integrations)) + yield* integrations.transform((editor) => { editor.method.update({ integrationID: Integration.ID.make("opencode"), method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] }, }) }) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -131,8 +129,8 @@ describe("OpencodePlugin", () => { draft.cost = [...paid.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -142,9 +140,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode", { request: { headers: {}, @@ -159,8 +156,8 @@ describe("OpencodePlugin", () => { draft.cost = [...paid.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -170,9 +167,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("openai") catalog.provider.update(item.id, () => {}) const paid = model("openai", "paid", { cost: cost(1) }) @@ -180,8 +176,8 @@ describe("OpencodePlugin", () => { draft.cost = [...paid.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -191,26 +187,25 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(1, 1)] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(10, 10)] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) }) const selected = yield* catalog.model.small(providerID) - expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) + expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) }).pipe( Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))), ), diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index fe8ccb62331..83566d416f4 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -6,7 +6,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, model, provider } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, model, provider, required } from "./provider-helper" describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => @@ -22,9 +22,8 @@ describe("OpenRouterPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, OpenRouterPlugin) + yield* catalog.transform((catalog) => { const openrouter = provider("openrouter", { api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, request: { headers: { Existing: "value" }, body: {} }, @@ -36,19 +35,19 @@ describe("OpenRouterPlugin", () => { catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({}) }), ) it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenRouterPlugin) + yield* addPlugin(plugin, OpenRouterPlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", @@ -74,9 +73,8 @@ describe("OpenRouterPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, OpenRouterPlugin) + yield* catalog.transform((catalog) => { const openrouter = provider("openrouter", { api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, }) @@ -94,12 +92,12 @@ describe("OpenRouterPlugin", () => { }) expect( - (yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled, ).toBe(false) expect( - (yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled, ).toBe(true) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true) }), ) @@ -107,14 +105,13 @@ describe("OpenRouterPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, OpenRouterPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect( - (yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))) + required(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, ).toBe(true) }), diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index 444badd8564..35498d5e9e8 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -3,13 +3,13 @@ import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, @@ -22,7 +22,7 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -40,7 +40,7 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) yield* plugin.add({ id: PluginV2.ID.make("perplexity-sdk-inspector"), effect: Effect.succeed({ @@ -63,7 +63,7 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) yield* plugin.add({ id: PluginV2.ID.make("custom-perplexity-sdk-inspector"), effect: Effect.succeed({ @@ -90,7 +90,7 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) const result = yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 565b9280ab9..51103167b56 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,10 +1,17 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Npm } from "@opencode-ai/core/npm" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" +import { host } from "./host" -const pluginWithNpm = { id: SapAICorePlugin.id, effect: SapAICorePlugin.effect.pipe(Effect.provide(npmLayer)) } +const pluginWithNpm = { + id: SapAICorePlugin.id, + effect: Effect.gen(function* () { + yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service })) + }).pipe(Effect.provide(npmLayer)), +} describe("SapAICorePlugin", () => { it.effect("copies serviceKey option into AICORE_SERVICE_KEY but keeps SDK options to deployment metadata", () => diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index ff5ec4ba451..5de7ae06588 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" -import { expectPluginRegistered, it, model, withEnv } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, model, withEnv } from "./provider-helper" describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => @@ -20,7 +20,7 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, @@ -34,7 +34,7 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -53,7 +53,7 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -76,7 +76,7 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -95,7 +95,7 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -119,7 +119,7 @@ describe("SnowflakeCortexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const captured: Record[] = [] - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) yield* plugin.add({ id: PluginV2.ID.make("inspector"), effect: Effect.succeed({ diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index 3457c2ac822..19757e126eb 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -2,13 +2,13 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin(plugin, TogetherAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, @@ -21,7 +21,7 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin(plugin, TogetherAIPlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", @@ -47,7 +47,7 @@ describe("TogetherAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin(plugin, TogetherAIPlugin) yield* plugin.add({ id: PluginV2.ID.make("inspector"), effect: Effect.succeed({ @@ -76,7 +76,7 @@ describe("TogetherAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin(plugin, TogetherAIPlugin) const result = yield* plugin.trigger( "aisdk.language", diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index ff4a922ab1e..148a30ee46e 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -2,13 +2,13 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VenicePlugin) + yield* addPlugin(plugin, VenicePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, @@ -22,7 +22,7 @@ describe("VenicePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* plugin.add(VenicePlugin) + yield* addPlugin(plugin, VenicePlugin) yield* plugin.add({ id: PluginV2.ID.make("inspector"), effect: Effect.succeed({ @@ -49,7 +49,7 @@ describe("VenicePlugin", () => { it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VenicePlugin) + yield* addPlugin(plugin, VenicePlugin) const similar = yield* plugin.trigger( "aisdk.sdk", { @@ -73,7 +73,7 @@ describe("VenicePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(VenicePlugin) + yield* addPlugin(plugin, VenicePlugin) const result = yield* plugin.trigger( "aisdk.language", { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index fe0e599ffb5..027f81d36d9 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -4,16 +4,15 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "./provider-helper" +import { addPlugin, it, model, provider, required } from "./provider-helper" describe("VercelPlugin", () => { it.effect("applies legacy lower-case referer headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, VercelPlugin) + yield* catalog.transform((catalog) => { const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" }, request: { headers: { Existing: "1" }, body: {} }, @@ -23,7 +22,7 @@ describe("VercelPlugin", () => { draft.request = item.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({ Existing: "1", "http-referer": "https://opencode.ai/", "x-title": "opencode", @@ -35,25 +34,24 @@ describe("VercelPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, VercelPlugin) + yield* catalog.transform((catalog) => { const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } }) catalog.provider.update(item.id, (draft) => { draft.api = item.api }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( "HTTP-Referer", ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty("X-Title") + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty("X-Title") }), ) it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VercelPlugin) + yield* addPlugin(plugin, VercelPlugin) const event = yield* plugin.trigger( "aisdk.sdk", { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, @@ -68,10 +66,9 @@ describe("VercelPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) + yield* addPlugin(plugin, VercelPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index e505f8538a4..4ac5cf34f18 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -6,7 +6,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk } from "./provider-helper" +import { addPlugin, fakeSelectorSdk } from "./provider-helper" const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) @@ -23,7 +23,7 @@ describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(XAIPlugin) + yield* addPlugin(plugin, XAIPlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", @@ -43,20 +43,18 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(XAIPlugin) - yield* plugin.add( - PluginV2.define({ - id: PluginV2.ID.make("xai-sdk-name-observer"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (!evt.sdk) return - providers.push(evt.sdk.responses("grok-4").provider) - }), - } - }), + yield* addPlugin(plugin, XAIPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("xai-sdk-name-observer"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (!evt.sdk) return + providers.push(evt.sdk.responses("grok-4").provider) + }), + } }), - ) + }) yield* plugin.trigger( "aisdk.sdk", @@ -77,7 +75,7 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(XAIPlugin) + yield* addPlugin(plugin, XAIPlugin) const result = yield* plugin.trigger( "aisdk.language", { @@ -98,7 +96,7 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(XAIPlugin) + yield* addPlugin(plugin, XAIPlugin) const result = yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 101d652615f..4bfdc7b0e14 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -5,7 +5,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => @@ -21,9 +21,8 @@ describe("ZenmuxPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, ZenmuxPlugin) + yield* catalog.transform((catalog) => { const item = provider("zenmux", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, }) @@ -31,7 +30,7 @@ describe("ZenmuxPlugin", () => { draft.api = item.api }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux")) + const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) }), @@ -41,9 +40,8 @@ describe("ZenmuxPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, ZenmuxPlugin) + yield* catalog.transform((catalog) => { const item = provider("zenmux", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, request: { headers: { Existing: "value" }, body: {} }, @@ -54,7 +52,7 @@ describe("ZenmuxPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -66,9 +64,8 @@ describe("ZenmuxPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, ZenmuxPlugin) + yield* catalog.transform((catalog) => { const item = provider("zenmux", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, request: { @@ -82,7 +79,7 @@ describe("ZenmuxPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) @@ -93,9 +90,8 @@ describe("ZenmuxPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, ZenmuxPlugin) + yield* catalog.transform((catalog) => { const item = provider("openrouter", { request: { headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, @@ -107,7 +103,7 @@ describe("ZenmuxPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 63d028e4ec0..9fefec820fe 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -6,6 +6,7 @@ import { SkillPlugin } from "@opencode-ai/core/plugin/skill" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { testEffect } from "../lib/effect" +import { host } from "./host" const it = testEffect( SkillV2.layer.pipe( @@ -19,7 +20,7 @@ describe("SkillPlugin.Plugin", () => { it.effect("registers the built-in customize-opencode skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill)) + yield* SkillPlugin.Plugin.effect(host({ skill })) expect(yield* skill.list()).toContainEqual( expect.objectContaining({ diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index dfa8a202a60..a5a94b9d2b3 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -17,7 +17,6 @@ describe("Reference", () => { Effect.gen(function* () { const references = yield* Reference.Service const scope = yield* Scope.make() - const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope)) const path = AbsolutePath.make("/docs") const source = new Reference.LocalSource({ type: "local", @@ -25,7 +24,7 @@ describe("Reference", () => { description: "Use for API documentation", hidden: true, }) - yield* update((editor) => editor.add("docs", source)) + yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope)) expect(yield* references.list()).toEqual([ new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }), @@ -44,10 +43,9 @@ describe("Reference", () => { it.effect("derives Git paths without exposing cache operations", () => Effect.gen(function* () { const references = yield* Reference.Service - const update = yield* references.transform() const repository = Repository.parseRemote("owner/repo") const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" }) - yield* update((editor) => editor.add("sdk", source)) + yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ new Reference.Info({ @@ -68,14 +66,13 @@ describe("Reference", () => { it.effect("preserves configured Git descriptions", () => Effect.gen(function* () { const references = yield* Reference.Service - const update = yield* references.transform() const repository = Repository.parseRemote("owner/repo") const source = new Reference.GitSource({ type: "git", repository: "owner/repo", description: "Use for SDK implementation details", }) - yield* update((editor) => editor.add("sdk", source)) + yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ new Reference.Info({ diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index e1f5e32b752..a03ec77f031 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -36,7 +36,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => options: { store: false, serviceTier: "priority" }, }, variants, - time: { released: DateTime.makeUnsafe(0) }, + time: { released: 0 }, cost: [], status: "active", enabled: true, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6ff969fb62a..0cc27508558 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -819,7 +819,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agent = yield* AgentV2.Service - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.system = "Build agent instructions" agent.mode = "primary" @@ -840,7 +840,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agent = yield* AgentV2.Service - yield* agent.update((editor) => { + yield* agent.transform((editor) => { editor.update(AgentV2.ID.make("build"), (agent) => { agent.system = "Build agent instructions" agent.mode = "primary" @@ -868,7 +868,7 @@ describe("SessionRunnerLLM", () => { yield* setup const { db } = yield* Database.Service const agent = yield* AgentV2.Service - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.system = "Reviewer instructions" agent.mode = "primary" @@ -3214,7 +3214,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index d0e01d0677e..46ee13d1201 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -59,8 +59,7 @@ describe("SkillV2", () => { }) const skill = yield* SkillV2.Service - const register = yield* skill.transform() - yield* register((editor) => { + yield* skill.transform((editor) => { editor.source({ type: "directory", path: AbsolutePath.make(first) }) editor.source({ type: "directory", path: AbsolutePath.make(first) }) editor.source({ type: "directory", path: AbsolutePath.make(second) }) @@ -108,15 +107,14 @@ describe("SkillV2", () => { urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)]) const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" }) }), ) const skill = yield* SkillV2.Service - const register = yield* skill.transform() - yield* register((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) + yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index cb795f85682..70d522c3fc3 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -13,13 +13,16 @@ describe("State", () => { let block = true const state = State.create({ initial: () => ({ values: [] as string[] }), - editor: (draft) => ({ add: (value: string) => draft.values.push(value) }), + draft: (draft) => ({ add: (value: string) => draft.values.push(value) }), finalize: () => block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void, }) const scope = yield* Scope.make() - const update = yield* state.transform().pipe(Scope.provide(scope)) - const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild) + const fiber = yield* state + .transform((editor) => { + editor.add("registered") + }) + .pipe(Scope.provide(scope), Effect.forkChild) yield* Deferred.await(rebuilding) const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild) block = false @@ -31,4 +34,82 @@ describe("State", () => { expect(state.get().values).toEqual([]) }), ) + + it.effect("runs effectful transforms during every rebuild", () => + Effect.gen(function* () { + let value = "first" + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + }) + + yield* state.transform((editor) => + Effect.sync(() => { + editor.add(value) + }), + ) + expect(state.get().values).toEqual(["first"]) + + value = "second" + yield* state.rebuild() + expect(state.get().values).toEqual(["second"]) + }), + ) + + it.effect("disposes a transform once and rebuilds remaining state", () => + Effect.gen(function* () { + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + }) + yield* state.transform((editor) => { + editor.add("first") + }) + const registration = yield* state.transform((editor) => { + editor.add("second") + }) + expect(state.get().values).toEqual(["first", "second"]) + + yield* registration.dispose + expect(state.get().values).toEqual(["first"]) + + yield* registration.dispose + expect(state.get().values).toEqual(["first"]) + }), + ) + + it.effect("batches automatic rebuilds", () => + Effect.gen(function* () { + let finalized = 0 + const first = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + finalize: () => Effect.sync(() => finalized++), + }) + const second = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + finalize: () => Effect.sync(() => finalized++), + }) + + yield* State.batch( + Effect.gen(function* () { + yield* first.transform((draft) => { + draft.add("first") + }) + yield* first.transform((draft) => { + draft.add("second") + }) + yield* second.transform((draft) => { + draft.add("third") + }) + expect(finalized).toBe(0) + }), + ) + + expect(first.get().values).toEqual(["first", "second"]) + expect(second.get().values).toEqual(["third"]) + expect(finalized).toBe(2) + }), + ) }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 8c08454c4f4..3a54be6c0b7 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -46,6 +46,7 @@ describe("SkillTool", () => { const boot = Layer.succeed( PluginBoot.Service, PluginBoot.Service.of({ + add: () => Effect.void, wait: () => Effect.sync(() => { bootWaited = true @@ -69,7 +70,8 @@ describe("SkillTool", () => { const skills = Layer.succeed( SkillV2.Service, SkillV2.Service.of({ - transform: () => Effect.die("unused"), + transform: (_transform) => Effect.die("unused"), + rebuild: () => Effect.die("unused"), sources: () => Effect.die("unused"), list: () => Effect.succeed(current), }), diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 74288529d4c..67a3e29cc1f 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" @@ -19,15 +19,13 @@ export const V2Command = effectCmd({ const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id)) const result = { providers, - default: catalog.model - .default() - .pipe(Effect.map(Option.map((item) => item.id)), Effect.map(Option.getOrUndefined)), + default: catalog.model.default().pipe(Effect.map((item) => item?.id)), small: Object.fromEntries( yield* Effect.all( all.map((provider) => Effect.map( catalog.model.small(provider.id), - (model) => [provider.id, Option.getOrUndefined(Option.map(model, (item) => item.id))] as const, + (model) => [provider.id, model?.id] as const, ), ), { concurrency: "unbounded" }, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d046e4e9f6d..16a0a6b5e8c 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -11,12 +11,14 @@ "exports": { ".": "./src/index.ts", "./tool": "./src/tool.ts", - "./tui": "./src/tui.ts" + "./tui": "./src/tui.ts", + "./v2/effect": "./src/v2/effect/index.ts" }, "files": [ "dist" ], "dependencies": { + "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:" diff --git a/packages/plugin/src/v2/effect/PLAN.md b/packages/plugin/src/v2/effect/PLAN.md new file mode 100644 index 00000000000..71fa07bd7b0 --- /dev/null +++ b/packages/plugin/src/v2/effect/PLAN.md @@ -0,0 +1,515 @@ +# V2 Plugin System Implementation Plan + +## Status + +This document describes the agreed target design for the V2 plugin system. It is an implementation plan, not documentation for the current API. + +## Goals + +- Internal and external plugins use the same public plugin API. +- Effect plugins import `@opencode-ai/plugin/v2/effect`, not `@opencode-ai/core`. +- Public domain values use generated `@opencode-ai/sdk` types. +- Core may retain branded IDs, decoded Effect schemas, and internal service types. +- Plugins may register replayable domain transforms and runtime hooks imperatively during setup. +- Registrations are scoped, independently disposable, ordered, and removable. +- Dynamic sources such as models.dev, config files, and skill directories can rebuild one domain without reloading the entire Location. +- The initial implementation covers the Effect API. A Promise API will be designed afterward as a wrapper over the same capabilities. + +## Authoring Model + +A plugin setup effect receives `PluginHost` and imperatively registers transforms and hooks. + +```ts +export const Plugin = define({ + id: "example", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) + }), + ) + + yield* ctx.tool.hook( + "execute.before", + Effect.fn(function* (event) { + event.args.update(sanitizeArgs) + }), + ) + }), +}) +``` + +Plugin setup does not return hooks. + +## Public Naming + +Settled names: + +- Replayable domain registration: `transform` +- Explicit domain replay: `rebuild` +- Runtime callback registration: `hook` +- Registration cleanup: `dispose` +- Event domain: singular `event` +- Other domains are singular: `agent`, `command`, `integration`, `reference`, `session`, `skill`, and `tool`; `catalog` remains `catalog` +- Hook names use dotted lifecycle names such as `"execute.before"` and `"execute.after"` + +## Transform API + +Each transformable domain exposes: + +```ts +interface TransformDomain { + transform(callback: (editor: Editor) => Effect.Effect): Effect.Effect + + rebuild(): Effect.Effect +} +``` + +The actual callback may be represented with the project's normal `Effect.fn` style. + +```ts +const registration = + yield * + ctx.catalog.transform( + Effect.fn(function* (catalog) { + const integration = yield* ctx.integration.get("anthropic") + if (!integration) return + + catalog.provider.update("anthropic", (provider) => { + provider.name = "Anthropic" + }) + }), + ) +``` + +Transforms may perform arbitrary Effects, including reads from other PluginHost services, filesystem I/O, and network I/O. Reads from another domain observe that domain's latest committed state. + +Transforms have no typed error channel. Unexpected failures are defects. + +## Transform Semantics + +- Every call to `transform()` creates an independent registration. +- Multiple transforms from one plugin and domain are allowed. +- Transform order is plugin registration order, then transform registration order within the plugin. +- A transform is automatically removed when its registration scope closes. +- `Registration.dispose` removes it early and is idempotent. +- Registering or disposing a transform automatically rebuilds its domain. +- During bulk plugin boot, automatic rebuilds are deferred and each affected domain is rebuilt once after the batch. +- `rebuild()` waits until replay and finalization complete. +- `rebuild()` always replays every active transform for the domain. +- Rebuilds are serialized and coalesced. Calls arriving during an active rebuild schedule at most one additional rebuild. +- A rebuild captures its registration list at the start. Concurrent registration changes affect the next rebuild. +- Transforms may not register or dispose transforms while replaying. Such changes are rejected or deferred by the runtime. +- Calling `rebuild()` for the currently rebuilding domain from one of its transforms is rejected. +- Rebuilding another domain from a transform is deferred until the current transform finishes. + +## Registration API + +Transforms and runtime hooks return the same Effect registration type. + +```ts +interface Registration { + readonly dispose: Effect.Effect +} +``` + +Registration behavior: + +- Automatically attached to the current `Scope.Scope` +- Explicitly disposable before scope closure +- Disposal affects future replays or invocations +- An in-flight rebuild or hook invocation uses the registration snapshot captured when it started and is allowed to finish + +## Runtime Hook API + +Domains expose runtime interception through `hook()`. + +```ts +const registration = + yield * + ctx.tool.hook( + "execute.before", + Effect.fn(function* (event) { + event.args.update(sanitizeArgs) + }), + ) +``` + +Runtime hook behavior: + +- Multiple registrations for the same hook are allowed. +- Hooks run sequentially in plugin and registration order. +- Later hooks observe mutations made by earlier hooks. +- Hook registration is scope-owned and independently disposable. +- Disposal affects future invocations; an in-flight invocation finishes using its captured registration snapshot. +- Runtime hooks are not replayed during domain rebuilds. +- Runtime hook callbacks have no typed error channel. + +## Hook Contexts + +Each hook receives one purpose-built context object rather than separate input/output parameters. + +```ts +ctx.tool.hook("execute.before", (event) => { + event.args.update((args) => ({ + ...args, + timeout: 30, + })) +}) +``` + +Hook context objects may contain: + +- Readonly SDK-typed operation data +- Purpose-built methods for allowed mutations +- Capability methods where the operation requires more than field assignment + +They must not expose core drafts or unrestricted internal objects. + +## Domain Transforms Versus Runtime Hooks + +Both use the same low-level scoped registration registry, but consumers invoke them differently. + +```ts +ctx.tool.transform(...) // replayed to build effective tool registry state +ctx.tool.hook(...) // invoked at a live tool operation boundary +``` + +The shared low-level machinery owns registration order, scope cleanup, disposal, and snapshots. Each domain owns when its transforms or runtime hooks execute. + +## Event API + +The Effect API exposes the existing event system as typed streams using generated SDK event discriminants. + +```ts +ctx.event.subscribe("catalog.updated") +// Stream.Stream +``` + +Example: + +```ts +yield * + ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => ctx.agent.rebuild()), + Effect.forkScoped, + ) +``` + +The plugin package derives event payload types from the generated SDK `Event` union: + +```ts +type EventMap = { + [Item in Event as Item["type"]]: Item +} +``` + +Core resolves the public event type string to its internal event definition and delegates to `EventV2.Service.subscribe`. + +## Domain State Model + +Each transformable core service continues to own: + +- Base state +- Effective committed state +- Editor creation +- Ordered transform registrations for that domain +- Rebuild serialization and coalescing +- Core finalization +- Commit and post-commit events + +The initial implementation should evolve the existing generic `State` helper rather than create a central cross-domain state manager. + +```text +base state +→ replay active transforms in order +→ core domain finalization +→ commit effective state +→ publish updated event +``` + +No cross-domain transform or transaction API is included. + +## Finalization + +Each domain has one plugin transform phase followed by core finalization. + +Core finalization is for invariants and materialization, not plugin extension behavior. + +Examples: + +- Catalog policy filtering and validation +- Reference repository materialization +- Integration connection projection +- Index construction +- Post-commit update events + +Finalizers should distinguish pre-commit work from post-commit notification. Update events should publish after the new state is visible. + +## Plugin Order + +The default distribution uses an opinionated internal order: + +```text +1. Built-in agents, commands, and skills +2. Base data sources such as models.dev +3. Configuration projections +4. Provider-specific normalization and authentication +5. External user plugins +6. Core domain finalization +``` + +For catalog transforms: + +```text +models.dev +→ config provider overrides +→ built-in provider normalization +→ user catalog transforms +→ catalog finalization +``` + +This replaces the current distinction between setup-installed State transforms and catalog hooks invoked from the catalog finalizer. + +Replacing a plugin with the same ID retains its existing order position. The old plugin is disabled before the replacement setup starts. + +## Boot Batching + +Plugin boot runs in an internal registration batch. + +```text +begin batch +→ initialize plugins sequentially +→ register transforms and hooks +→ collect affected domains +→ rebuild each affected domain once +→ end batch +``` + +Registration itself is not staged per plugin. If setup fails, closing the plugin's child scope removes every registration made before the failure. + +Outside a batch, transform registration and disposal rebuild immediately. + +## Models.dev Example + +Models.dev performs effectful reads directly from its transforms and rebuilds affected domains after refresh. + +```ts +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: (ctx) => + Effect.gen(function* () { + const modelsDev = yield* ModelsDev.Service + const event = yield* EventV2.Service + + yield* ctx.integration.transform( + Effect.fn(function* (integration) { + const data = yield* modelsDev.get() + applyIntegrations(data, integration) + }), + ) + + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() + applyCatalog(data, catalog) + }), + ) + + yield* event.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach( + Effect.fn(function* () { + yield* ctx.integration.rebuild() + yield* ctx.catalog.rebuild() + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) +``` + +The two domains rebuild sequentially. This plan does not add a cross-domain atomic transaction. + +## Config Watcher Example + +```ts +export const ConfigPlugin = define({ + id: "config", + effect: (ctx) => + Effect.gen(function* () { + const config = yield* ConfigSource.Service + + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + applyAgentConfig(yield* config.get(), agent) + }), + ) + + yield* ctx.command.transform( + Effect.fn(function* (command) { + applyCommandConfig(yield* config.get(), command) + }), + ) + + yield* config.changes.pipe( + Stream.runForEach( + Effect.fn(function* () { + yield* ctx.agent.rebuild() + yield* ctx.command.rebuild() + }), + ), + Effect.forkScoped, + ) + }), +}) +``` + +## Cross-Domain Read Example + +A transform may read another committed service. It must still arrange for its own domain to rebuild when that dependency changes. + +```ts +export const AnthropicAgentPlugin = define({ + id: "anthropic-agent", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + const providers = yield* ctx.catalog.provider.list() + if (!providers.some((provider) => provider.id === "anthropic")) return + + agent.update("anthropic-reviewer", (item) => { + item.description = "Reviews code using Anthropic" + item.mode = "subagent" + item.model = { + providerID: "anthropic", + id: "claude-sonnet", + } + }) + }), + ) + + yield* ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => ctx.agent.rebuild()), + Effect.forkScoped, + ) + }), +}) +``` + +The runtime does not infer cross-domain dependencies. + +## Embedding API Compatibility + +The imperative registration model maps naturally to a future application embedding API: + +```ts +const registration = oc.agent.transform((agent) => { + agent.update("reviewer", configureReviewer) +}) + +registration.dispose() +``` + +An application registration is stored as an application-level plugin registration. It attaches to every current Location and is installed during future Location boot. Disposal removes all current attachments and prevents future attachment. + +The Effect implementation remains the canonical runtime. Promise and embedding wrappers are deferred until after the Effect API is stable. + +## Migration Plan + +### 1. Define Public Contracts + +- Define `PluginHost` domain capabilities in `@opencode-ai/plugin/v2/effect`. +- Define SDK-typed editors for agent, catalog, command, integration, reference, skill, and tool. +- Define typed runtime hook maps per domain. +- Define `Registration`. +- Define typed `event.subscribe(type)`. + +### 2. Generalize Registration Machinery + +- Add one low-level scoped registration registry used by transforms and runtime hooks. +- Preserve plugin order and registration order. +- Support idempotent disposal and registration snapshots. +- Retain plugin position during same-ID replacement. + +### 3. Evolve State + +- Replace the current returned transform-slot updater with direct `transform(callback)` registration. +- Support Effectful callbacks. +- Add public `rebuild()`. +- Add rebuild serialization and coalescing. +- Add boot batching that defers automatic rebuilds. +- Move update event publication after commit. + +### 4. Expand Domain Transform Hooks + +- Agent +- Catalog +- Command +- Integration +- Reference +- Skill +- Tool + +### 5. Migrate Existing Plugins + +- Built-in agent transform +- Built-in command transform +- Built-in skill transform +- Models.dev catalog and integration transforms +- Config transforms +- OpenAI integration transform +- Provider catalog transforms + +### 6. Migrate Runtime Hooks + +- AI SDK resolution +- Language model resolution +- Tool execution hooks +- Session prompt/context hooks as required + +### 7. Remove Returned Hooks + +- Remove `HookFunctions` as the plugin setup return value. +- Remove catalog's special finalizer-triggered plugin hook path. +- Remove `plugin.added` catalog mutation handling. +- Make add/remove/replacement rely on scoped registration and domain rebuilds. + +### 8. Add Event Adapter + +- Build the SDK event discriminant map. +- Resolve public type strings to internal EventV2 definitions. +- Return typed Effect streams. + +### 9. Verification + +- Transform order is deterministic. +- Multiple transforms per plugin/domain compose. +- Registration and disposal rebuild automatically outside boot batches. +- Boot performs one rebuild per affected domain. +- Plugin setup failure removes prior registrations. +- Same-ID replacement retains order and disables the old plugin first. +- Rebuilds serialize and coalesce. +- Registration changes during replay affect the next rebuild. +- Same-domain recursive rebuild is rejected. +- Cross-domain rebuild requests from transforms are deferred. +- Hook execution is sequential and snapshot-based. +- Models.dev refresh replays config and provider transforms. +- Config and skill watcher refreshes remove stale entries. +- Plugin removal restores prior effective state. +- Events observe newly committed state. + +## Deferred Decisions + +- Promise API shape +- Typed error model +- Transform timeouts +- Cross-domain atomic rebuilds +- Automatic dependency tracking +- Whole-Location generation reload +- Exact editors and runtime hooks not required by current plugins diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md new file mode 100644 index 00000000000..4fbf469d879 --- /dev/null +++ b/packages/plugin/src/v2/effect/README.md @@ -0,0 +1,585 @@ +# OpenCode V2 Plugin API + +> Design proposal. The API shown here is the intended V2 model and is not fully implemented yet. + +This document explains how OpenCode V2 plugins contribute agents, commands, skills, integrations, providers, and models without importing `@opencode-ai/core`. + +The design has four goals: + +- Internal and external plugins use the same API. +- Plugin values use generated `@opencode-ai/sdk` types. +- Core may keep richer internal representations such as branded IDs and decoded Effect schemas. +- Plugins can react to changing data without reloading an entire Location. + +## Mental Model + +A plugin has two parts: + +1. A setup effect that loads data, starts scoped subscriptions, and returns hooks. +2. Singular transform hooks that describe the plugin's current contribution to a domain. + +```ts +export default defineEffectPlugin({ + id: "example", + effect: (ctx) => + Effect.gen(function* () { + return { + "agent.transform": (agent) => { + // Describe this plugin's agent contribution. + }, + } + }), +}) +``` + +A transform is not a one-time mutation. It is a replayable declaration. + +OpenCode may run it when: + +- The plugin is added. +- The plugin is removed or replaced. +- Another plugin affecting the same domain changes. +- The plugin explicitly invalidates the domain. + +Transforms must therefore be synchronous, deterministic, and safe to rerun. + +## Why Hooks Are Returned + +Each transform is a singular property of the plugin definition: + +```ts +return { + "catalog.transform": applyCatalog, +} +``` + +This makes it structurally clear that one plugin has at most one transform per domain. There is no ambiguous behavior from calling `transform()` multiple times during setup. + +Transforms from different plugins compose in plugin order. + +```text +models.dev catalog transform +→ config catalog transform +→ provider catalog transforms +→ user catalog transforms +→ core catalog finalizer +``` + +## Your First Plugin + +This plugin adds a reviewer agent. + +```ts +import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default defineEffectPlugin({ + id: "reviewer", + effect: () => + Effect.succeed({ + "agent.transform": (agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for correctness and regressions" + item.system = "Review the requested code. Prioritize bugs and behavioral regressions." + item.mode = "subagent" + item.hidden = false + }) + }, + }), +}) +``` + +The editor supplies a complete default agent when `reviewer` does not exist. The callback modifies that value using the generated SDK agent shape. + +When the plugin unloads, OpenCode rebuilds the agent registry without this transform. The reviewer disappears automatically. + +## Transform Editors + +Editors support ordered reads and writes while a domain is being rebuilt. + +```ts +"agent.transform": (agent) => { + const existing = agent.get("reviewer") + + agent.update("reviewer", (item) => { + item.description ??= existing?.description ?? "Reviews code" + }) +} +``` + +An editor is valid only during the transform call. Do not retain it in plugin state. + +Later plugins see mutations made by earlier plugins in the same rebuild. + +## Adding A Provider And Model + +This plugin contributes one provider and one model. + +```ts +import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default defineEffectPlugin({ + id: "acme", + effect: () => + Effect.succeed({ + "catalog.transform": (catalog) => { + catalog.provider.update("acme", (provider) => { + provider.name = "Acme AI" + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.acme.example/v1", + } + }) + + catalog.model.update("acme", "acme-chat", (model) => { + model.name = "Acme Chat" + model.family = "acme" + model.api = { + id: "acme-chat", + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.acme.example/v1", + } + model.capabilities = { + tools: true, + input: ["text"], + output: ["text"], + } + model.time.released = Date.now() + model.status = "active" + model.enabled = true + model.limit = { + context: 128_000, + output: 16_384, + } + }) + }, + }), +}) +``` + +The provider and model values use generated SDK types. Core may encode and decode richer internal schema values at the plugin boundary. + +## Dynamic Data And Invalidation + +Some plugins depend on data that changes after setup. Examples include: + +- models.dev refreshes +- config file watchers +- skill directory watchers +- authentication state changes + +The plugin keeps the current data in its own scoped state. When that data changes, it invalidates each affected domain. + +```ts +let data = yield * loadData() + +return { + "catalog.transform": (catalog) => { + applyCatalog(data, catalog) + }, +} +``` + +After changing `data`: + +```ts +data = yield * loadData() +yield * ctx.catalog.invalidate() +``` + +Invalidation does not mutate the current catalog in place. It requests a rebuild: + +```text +create fresh catalog state +→ replay every catalog transform in plugin order +→ run the core catalog finalizer +→ commit the new catalog +→ publish catalog.updated +``` + +Repeated invalidations are serialized and may be coalesced. + +## Models.dev Example + +Models.dev is the main example of a dynamic plugin. It projects one changing source into the integration and catalog domains. + +```ts +import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" +import { Effect, Stream } from "effect" + +export default defineEffectPlugin({ + id: "models-dev", + effect: (ctx) => + Effect.gen(function* () { + const modelsDev = yield* ModelsDev.Service + const events = yield* EventV2.Service + let data = yield* modelsDev.get() + + yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach( + Effect.fn(function* () { + data = yield* modelsDev.get() + yield* ctx.integration.invalidate() + yield* ctx.catalog.invalidate() + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + + return { + "integration.transform": (integration) => { + for (const provider of Object.values(data)) { + if (provider.env.length === 0) continue + + integration.update(provider.id, (item) => { + item.name = provider.name + }) + + integration.method.update({ + integrationID: provider.id, + method: { type: "key" }, + }) + + integration.method.update({ + integrationID: provider.id, + method: { + type: "env", + names: [...provider.env], + }, + }) + } + }, + + "catalog.transform": (catalog) => { + for (const provider of Object.values(data)) { + applyProvider(provider, catalog) + } + }, + } + }), +}) +``` + +`ModelsDev.Service` and `ModelsDev.Event` are privileged internal dependencies in this example. The integration and catalog contributions still use the same hooks available to external plugins. + +This design intentionally does not require a special multi-domain transform. The two domains rebuild independently. If strict cross-domain atomic publication becomes a requirement, it should be designed separately rather than making every transform combinatorial. + +## Config File Watching + +A config plugin can project one parsed config snapshot into several independent domains. + +```ts +export default defineEffectPlugin({ + id: "config", + effect: (ctx) => + Effect.gen(function* () { + let config = yield* loadConfig() + + yield* watchConfig.pipe( + Stream.runForEach( + Effect.fn(function* () { + config = yield* loadConfig() + yield* ctx.agent.invalidate() + yield* ctx.command.invalidate() + yield* ctx.catalog.invalidate() + yield* ctx.integration.invalidate() + yield* ctx.reference.invalidate() + yield* ctx.skill.invalidate() + }), + ), + Effect.forkScoped, + ) + + return { + "agent.transform": (agent) => applyAgentConfig(config, agent), + "command.transform": (command) => applyCommandConfig(config, command), + "catalog.transform": (catalog) => applyProviderConfig(config, catalog), + "integration.transform": (integration) => applyIntegrationConfig(config, integration), + "reference.transform": (reference) => applyReferenceConfig(config, reference), + "skill.transform": (skill) => applySkillConfig(config, skill), + } + }), +}) +``` + +The watcher performs I/O. The transforms only project the latest in-memory snapshot. + +## Skill Directory Watching + +A skill plugin follows the same pattern. + +```ts +export default defineEffectPlugin({ + id: "workspace-skills", + effect: (ctx) => + Effect.gen(function* () { + let sources = yield* discoverSkills() + + yield* watchSkillDirectories.pipe( + Stream.runForEach( + Effect.fn(function* () { + sources = yield* discoverSkills() + yield* ctx.skill.invalidate() + }), + ), + Effect.forkScoped, + ) + + return { + "skill.transform": (skill) => { + for (const source of sources) skill.source(source) + }, + } + }), +}) +``` + +Rebuilding the source registry may not be enough if discovered skill contents are cached separately. Domain invalidation must include all materialized state owned by that domain. + +## Runtime Hooks + +Transform hooks build registry state. Runtime hooks intercept live operations. + +```ts +return { + "catalog.transform": (catalog) => { + // Synchronous and replayable. + }, + + "aisdk.sdk": Effect.fn(function* (event) { + // Runs when OpenCode needs an AI SDK provider. + }), + + "aisdk.language": Effect.fn(function* (event) { + // Runs when OpenCode selects a language model implementation. + }), +} +``` + +Runtime hooks may perform Effects appropriate to the operation. Transform hooks must remain replay-safe. + +## Integration Authentication + +Executable registrations may be installed during an integration transform. + +```ts +return { + "integration.transform": (integration) => { + integration.update("openai", (item) => { + item.name = "OpenAI" + }) + + integration.method.update({ + integrationID: "openai", + method: { + id: "chatgpt-browser", + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }, + authorize: browserAuthorize, + refresh: refreshCredential, + }) + }, +} +``` + +Replay installs callback values. It must not start OAuth, open a server, or refresh credentials. Those effects run later when core invokes the stored implementation. + +## Reading Other Domains + +A transform may need information from another committed domain. + +```ts +"agent.transform": (agent) => { + if (!anthropicAvailable) return + + agent.update("anthropic-reviewer", (item) => { + item.model = { + providerID: "anthropic", + id: "claude-sonnet", + } + }) +} +``` + +Load or subscribe to the dependency during setup, keep a local snapshot, and invalidate the dependent domain when the snapshot changes. + +```ts +let anthropicAvailable = yield * readAnthropicAvailability() + +yield * + catalogChanges.pipe( + Stream.runForEach( + Effect.fn(function* () { + anthropicAvailable = yield* readAnthropicAvailability() + yield* ctx.agent.invalidate() + }), + ), + Effect.forkScoped, + ) +``` + +This keeps transform callbacks synchronous and avoids hidden dependency tracking. + +## Plugin Order + +OpenCode's default distribution uses an opinionated order. + +```text +1. Built-in agents, commands, and skills +2. Base data sources such as models.dev +3. Configuration projections +4. Provider-specific normalization and authentication +5. External user plugins +6. Core domain finalization +``` + +For the catalog: + +```text +models.dev +→ config provider overrides +→ built-in provider normalization +→ user catalog transforms +→ policy and validation +→ commit +→ catalog.updated +``` + +Ordering is observable behavior. Later transforms see and may override earlier transforms. + +## Core Finalization + +Plugin transforms and core finalization are different concepts. + +Transforms describe configurable plugin contributions. Core finalization enforces domain invariants. + +Catalog finalization may: + +- Validate the materialized catalog. +- Apply provider-use policy. +- Build indexes. +- Commit the new snapshot. +- Publish `catalog.updated` after the new snapshot is visible. + +Reference finalization may materialize Git-backed references. Integration finalization may update connection projections and publish events. + +Core finalizers always run after plugin transforms for that domain. + +## Add, Remove, And Replace + +When a plugin is added, OpenCode invalidates every domain for which it returned a transform. + +When a plugin is removed, OpenCode removes its hooks and invalidates those domains. Rebuilding from base state automatically removes the plugin's prior mutations. + +When a plugin is replaced, OpenCode swaps its hooks, preserves the intended plugin order, and invalidates the affected domains. + +No plugin-specific undo callback is required. + +## Effect API + +The Effect API exposes Effect-native setup, runtime hooks, scopes, interruption, and typed failures. + +```ts +export type EffectPlugin = (ctx: EffectPluginContext) => Effect.Effect +``` + +The setup scope owns: + +- Event subscriptions +- Watchers +- Background fibers +- Plugin hooks + +Closing the scope unloads the plugin and invalidates its transformed domains. + +## Promise API + +The Promise API uses the same SDK values, hook names, editors, and lifecycle semantics. + +```ts +export default definePlugin({ + id: "reviewer", + plugin: async () => ({ + "agent.transform": (agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + item.hidden = false + }) + }, + }), +}) +``` + +Promise plugins receive Promise-returning host capabilities: + +```ts +await ctx.catalog.invalidate() +``` + +Core implements the Promise API by running the canonical Effect capabilities. It manages the plugin scope automatically. + +## Rules For Transform Hooks + +Transform hooks must: + +- Be synchronous. +- Be deterministic for their captured snapshot. +- Avoid network, filesystem, process, and database I/O. +- Avoid publishing events. +- Avoid invalidating a domain while that domain is rebuilding. +- Avoid retaining the editor after returning. + +Transform hooks may: + +- Read the editor's current materialized state. +- Add, update, and remove domain entries. +- Install executable callback values for later use. +- Read immutable or plugin-owned captured data. + +## Runtime Requirements + +The plugin runtime must provide these guarantees: + +- Hooks replay in deterministic plugin order. +- Only one rebuild per domain runs at a time. +- Repeated invalidations may be coalesced. +- Rebuilds use fresh temporary state. +- Failed rebuilds leave the previous committed state intact. +- Core finalization runs after all plugin transforms. +- Update events publish only after the new state is visible. +- Plugin add, remove, and replacement invalidate affected domains automatically. +- A transform cannot invalidate the domain currently running it. + +## Summary + +Use setup for effects and transforms for declarations. + +```ts +effect: (ctx) => + Effect.gen(function* () { + let data = yield* loadData() + + yield* watchData.pipe( + Stream.runForEach( + Effect.fn(function* () { + data = yield* loadData() + yield* ctx.catalog.invalidate() + }), + ), + Effect.forkScoped, + ) + + return { + "catalog.transform": (catalog) => { + applyCatalog(data, catalog) + }, + } + }) +``` + +The plugin owns changing source data. The runtime owns hook ordering, replay, invalidation, cleanup, and commit. Core services own their state and finalization. diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts new file mode 100644 index 00000000000..11a8c6c20ed --- /dev/null +++ b/packages/plugin/src/v2/effect/agent.ts @@ -0,0 +1,17 @@ +import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export interface AgentDraft { + list(): readonly AgentV2Info[] + get(id: string): AgentV2Info | undefined + default(id: string | undefined): void + update(id: string, update: (agent: AgentV2Info) => void): void + remove(id: string): void +} + +export interface Agent extends Transformable { + get(id: string): Effect.Effect + default(): Effect.Effect + list(): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts new file mode 100644 index 00000000000..579de82496e --- /dev/null +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -0,0 +1,21 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Hookable } from "./registration.js" + +export interface AISDKHooks { + readonly sdk: (event: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any + }) => Effect.Effect | void + readonly language: (event: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 + }) => Effect.Effect | void +} + +export interface AISDK extends Hookable {} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts new file mode 100644 index 00000000000..1d44717aefd --- /dev/null +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -0,0 +1,41 @@ +import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export interface CatalogProviderRecord { + readonly provider: ProviderV2Info + readonly models: ReadonlyMap +} + +export interface CatalogDraft { + readonly provider: { + list(): readonly CatalogProviderRecord[] + get(providerID: string): CatalogProviderRecord | undefined + update(providerID: string, update: (provider: ProviderV2Info) => void): void + remove(providerID: string): void + } + readonly model: { + get(providerID: string, modelID: string): ModelV2Info | undefined + update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void + remove(providerID: string, modelID: string): void + readonly default: { + get(): { providerID: string; modelID: string } | undefined + set(providerID: string, modelID: string): void + } + } +} + +export interface Catalog extends Transformable { + readonly provider: { + get(id: string): Effect.Effect + list(): Effect.Effect + available(): Effect.Effect + } + readonly model: { + get(providerID: string, modelID: string): Effect.Effect + list(): Effect.Effect + available(): Effect.Effect + default(): Effect.Effect + small(providerID: string): Effect.Effect + } +} diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts new file mode 100644 index 00000000000..fcb90d19685 --- /dev/null +++ b/packages/plugin/src/v2/effect/command.ts @@ -0,0 +1,15 @@ +import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export interface CommandDraft { + list(): readonly CommandV2Info[] + get(name: string): CommandV2Info | undefined + update(name: string, update: (command: CommandV2Info) => void): void + remove(name: string): void +} + +export interface Command extends Transformable { + get(name: string): Effect.Effect + list(): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts new file mode 100644 index 00000000000..e6ea7cf0ce9 --- /dev/null +++ b/packages/plugin/src/v2/effect/event.ts @@ -0,0 +1,10 @@ +import type { Event as SDKEvent } from "@opencode-ai/sdk/v2/types" +import type { Stream } from "effect" + +export type EventMap = { + [Item in SDKEvent as Item["type"]]: Item +} + +export interface Event { + subscribe(type: Type): Stream.Stream +} diff --git a/packages/plugin/src/v2/effect/filesystem.ts b/packages/plugin/src/v2/effect/filesystem.ts new file mode 100644 index 00000000000..d242b2a692f --- /dev/null +++ b/packages/plugin/src/v2/effect/filesystem.ts @@ -0,0 +1,17 @@ +import type { FileSystemEntry } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" + +export interface FileSystem { + read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> + list(input?: { readonly path?: string }): Effect.Effect + find(input: { + readonly query: string + readonly type?: "file" | "directory" + readonly limit?: number + }): Effect.Effect + glob(input: { + readonly pattern: string + readonly path?: string + readonly limit?: number + }): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/host.ts b/packages/plugin/src/v2/effect/host.ts new file mode 100644 index 00000000000..707f744b35f --- /dev/null +++ b/packages/plugin/src/v2/effect/host.ts @@ -0,0 +1,27 @@ +import type { Agent } from "./agent.js" +import type { AISDK } from "./aisdk.js" +import type { Catalog } from "./catalog.js" +import type { Command } from "./command.js" +import type { Event } from "./event.js" +import type { FileSystem } from "./filesystem.js" +import type { Integration } from "./integration.js" +import type { Location } from "./location.js" +import type { Npm } from "./npm.js" +import type { Path } from "./path.js" +import type { Reference } from "./reference.js" +import type { Skill } from "./skill.js" + +export interface PluginHost { + readonly agent: Agent + readonly aisdk: AISDK + readonly catalog: Catalog + readonly command: Command + readonly event: Event + readonly filesystem: FileSystem + readonly integration: Integration + readonly location: Location + readonly npm: Npm + readonly path: Path + readonly reference: Reference + readonly skill: Skill +} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts new file mode 100644 index 00000000000..46c4574515f --- /dev/null +++ b/packages/plugin/src/v2/effect/index.ts @@ -0,0 +1,17 @@ +export type { PluginHost } from "./host.js" +export { define } from "./plugin.js" +export type { Plugin } from "./plugin.js" +export type { Registration } from "./registration.js" +export type { Agent, AgentDraft } from "./agent.js" +export type { AISDK, AISDKHooks } from "./aisdk.js" +export type { Catalog, CatalogDraft, CatalogProviderRecord } from "./catalog.js" +export type { Command, CommandDraft } from "./command.js" +export type { Event, EventMap } from "./event.js" +export type { FileSystem } from "./filesystem.js" +export type { Integration, IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "./integration.js" +export type { Location } from "./location.js" +export type { Npm } from "./npm.js" +export type { Path } from "./path.js" +export type { Reference, ReferenceDraft } from "./reference.js" +export type { Hookable, Transform, Transformable } from "./registration.js" +export type { Skill, SkillDraft, SkillSource } from "./skill.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts new file mode 100644 index 00000000000..2acb08b5793 --- /dev/null +++ b/packages/plugin/src/v2/effect/integration.ts @@ -0,0 +1,36 @@ +import type { + IntegrationEnvMethod, + IntegrationInfo, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod +export type IntegrationMethodRegistration = + | { + readonly integrationID: string + readonly method: IntegrationKeyMethod + } + | { + readonly integrationID: string + readonly method: IntegrationEnvMethod + } + +export interface IntegrationDraft { + list(): readonly Pick[] + get(id: string): Pick | undefined + update(id: string, update: (integration: Pick) => void): void + remove(id: string): void + readonly method: { + list(integrationID: string): readonly IntegrationMethod[] + update(input: IntegrationMethodRegistration): void + remove(integrationID: string, method: IntegrationMethod): void + } +} + +export interface Integration extends Transformable { + get(id: string): Effect.Effect + list(): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/location.ts b/packages/plugin/src/v2/effect/location.ts new file mode 100644 index 00000000000..bc546a3b175 --- /dev/null +++ b/packages/plugin/src/v2/effect/location.ts @@ -0,0 +1,6 @@ +export interface Location { + readonly directory: string + readonly project: { + readonly directory: string + } +} diff --git a/packages/plugin/src/v2/effect/npm.ts b/packages/plugin/src/v2/effect/npm.ts new file mode 100644 index 00000000000..4cb96c32d17 --- /dev/null +++ b/packages/plugin/src/v2/effect/npm.ts @@ -0,0 +1,11 @@ +import type { Effect } from "effect" + +export interface Npm { + add(pkg: string): Effect.Effect< + { + readonly directory: string + readonly entrypoint?: string + }, + unknown + > +} diff --git a/packages/plugin/src/v2/effect/path.ts b/packages/plugin/src/v2/effect/path.ts new file mode 100644 index 00000000000..f9045cc32d0 --- /dev/null +++ b/packages/plugin/src/v2/effect/path.ts @@ -0,0 +1,8 @@ +export interface Path { + readonly home: string + readonly data: string + readonly cache: string + readonly config: string + readonly state: string + readonly temp: string +} diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts new file mode 100644 index 00000000000..09c919ad6b0 --- /dev/null +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -0,0 +1,11 @@ +import type { Effect, Scope } from "effect" +import type { PluginHost } from "./host.js" + +export interface Plugin { + readonly id: string + readonly effect: (host: PluginHost) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts new file mode 100644 index 00000000000..389674cff7e --- /dev/null +++ b/packages/plugin/src/v2/effect/reference.ts @@ -0,0 +1,13 @@ +import type { ReferenceGitSource, ReferenceInfo, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export interface ReferenceDraft { + add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void + remove(name: string): void + list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] +} + +export interface Reference extends Transformable { + list(): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts new file mode 100644 index 00000000000..05aa0c4b606 --- /dev/null +++ b/packages/plugin/src/v2/effect/registration.ts @@ -0,0 +1,16 @@ +import type { Effect, Scope } from "effect" + +export type Transform = (draft: Draft) => Effect.Effect | void + +export interface Registration { + readonly dispose: Effect.Effect +} + +export interface Transformable { + transform(callback: Transform): Effect.Effect + rebuild(): Effect.Effect +} + +export interface Hookable { + hook(name: Name, callback: Hooks[Name]): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts new file mode 100644 index 00000000000..d25a71f0d3e --- /dev/null +++ b/packages/plugin/src/v2/effect/skill.ts @@ -0,0 +1,18 @@ +import type { SkillV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export type SkillSource = + | { readonly type: "directory"; readonly path: string } + | { readonly type: "url"; readonly url: string } + | { readonly type: "embedded"; readonly skill: SkillV2Info } + +export interface SkillDraft { + source(source: SkillSource): void + list(): readonly SkillSource[] +} + +export interface Skill extends Transformable { + sources(): Effect.Effect + list(): Effect.Effect +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 37b6889078b..78643b6a1ef 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -15,7 +15,8 @@ "./v2": "./src/v2/index.ts", "./v2/client": "./src/v2/client.ts", "./v2/gen/client": "./src/v2/gen/client/index.ts", - "./v2/server": "./src/v2/server.ts" + "./v2/server": "./src/v2/server.ts", + "./v2/types": "./src/v2/gen/types.gen.ts" }, "files": [ "dist" diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 81e5e93ac79..8b1c9959e8e 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -30,16 +30,13 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han const catalog = yield* Catalog.Service const pluginBoot = yield* PluginBoot.Service yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) - return yield* response(catalog.provider.get(ctx.params.providerID)).pipe( - Effect.catchTag("CatalogV2.ProviderNotFound", (error) => - Effect.fail( - new ProviderNotFoundError({ - providerID: error.providerID, - message: `Provider not found: ${error.providerID}`, - }), - ), - ), - ) + const provider = yield* catalog.provider.get(ctx.params.providerID) + if (!provider) + return yield* new ProviderNotFoundError({ + providerID: ctx.params.providerID, + message: `Provider not found: ${ctx.params.providerID}`, + }) + return yield* response(Effect.succeed(provider)) }), ) }), From 02687b6324de55e05d551384d2b77ec752152692 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 21 Jun 2026 12:07:16 +0000 Subject: [PATCH 044/112] chore: generate --- packages/core/src/provider.ts | 3 +- packages/core/test/config/provider.test.ts | 12 +- packages/core/test/plugin/host.ts | 4 +- .../test/plugin/provider-anthropic.test.ts | 4 +- .../plugin/provider-github-copilot.test.ts | 6 +- packages/core/test/plugin/provider-helper.ts | 2 +- .../core/test/plugin/provider-openai.test.ts | 7 +- .../test/plugin/provider-opencode.test.ts | 8 +- .../test/plugin/provider-openrouter.test.ts | 12 +- .../core/test/plugin/provider-vercel.test.ts | 4 +- packages/opencode/src/cli/cmd/debug/v2.ts | 5 +- packages/sdk/js/src/v2/gen/types.gen.ts | 34 ++--- packages/sdk/openapi.json | 128 ++++++++---------- 13 files changed, 107 insertions(+), 122 deletions(-) diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index f12fd2c0688..63ab19e71e0 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -38,8 +38,7 @@ export const Native = Schema.Struct({ export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) export type Api = typeof Api.Type export type MutableApi = T extends Api - ? Omit, "settings"> & - (undefined extends T["settings"] ? { settings?: any } : { settings: any }) + ? Omit, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any }) : never export const Request = Schema.Struct({ diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 1a6ba447ae8..054c6871d58 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -61,9 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ...ConfigProviderPlugin.Plugin, effect: ConfigProviderPlugin.Plugin.effect( host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe( - Effect.provideService(Config.Service, config), - ), + ).pipe(Effect.provideService(Config.Service, config)), }) const model = required(yield* catalog.model.get(providerID, modelID)) @@ -122,9 +120,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ...ConfigProviderPlugin.Plugin, effect: ConfigProviderPlugin.Plugin.effect( host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe( - Effect.provideService(Config.Service, config), - ), + ).pipe(Effect.provideService(Config.Service, config)), }) const model = required(yield* catalog.model.get(providerID, modelID)) @@ -225,9 +221,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ...ConfigProviderPlugin.Plugin, effect: ConfigProviderPlugin.Plugin.effect( host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe( - Effect.provideService(Config.Service, config), - ), + ).pipe(Effect.provideService(Config.Service, config)), }) const provider = required(yield* catalog.provider.get(providerID)) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 17ebd7bda32..eb11dc30dd1 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -255,7 +255,9 @@ function method(value: Integration.Method) { } } -function internalMethod(value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod): Integration.Method { +function internalMethod( + value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod, +): Integration.Method { if (value.type === "env") return value if (value.type === "key") return value return { diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 9b496817e7b..d31574d0f69 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -35,7 +35,9 @@ describe("AnthropicPlugin", () => { const catalog = yield* Catalog.Service yield* addPlugin(plugin, AnthropicPlugin) yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"]).toBeUndefined() + expect( + required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], + ).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index bbc41646a33..d23672f6fe9 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -157,7 +157,8 @@ describe("GithubCopilotPlugin", () => { catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect( - required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(false) }), ) @@ -172,7 +173,8 @@ describe("GithubCopilotPlugin", () => { catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect( - required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 1d04d750561..d30286bc093 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -90,7 +90,7 @@ export function addPlugin(plugin: PluginV2.Interface, definition: Plugin) { const npm = yield* Effect.serviceOption(Npm.Service) const effect = typeof definition.effect === "function" - ? definition.effect( + ? definition.effect( host({ aisdk: aisdkHost(plugin), ...(Option.isSome(catalog) ? { catalog: catalogHost(catalog.value) } : {}), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 7a50bbf145b..d41a856ce75 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -118,7 +118,9 @@ describe("OpenAIPlugin", () => { catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) - expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false) + expect( + required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + ).toBe(false) }), ) @@ -133,7 +135,8 @@ describe("OpenAIPlugin", () => { catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect( - required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 5fb1b3b36aa..9fe4be97332 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -22,9 +22,7 @@ const locationLayer = Layer.succeed( const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({ ...OpencodePlugin, - effect: OpencodePlugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ), + effect: OpencodePlugin.effect(host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) })), }) describe("OpencodePlugin", () => { @@ -83,7 +81,9 @@ describe("OpencodePlugin", () => { }) }) expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true) + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe( + true, + ) }), ), ) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 83566d416f4..49b5875b5e0 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -92,12 +92,15 @@ describe("OpenRouterPlugin", () => { }) expect( - required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))) + .enabled, ).toBe(false) expect( required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled, ).toBe(true) - expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true) + expect( + required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled, + ).toBe(true) }), ) @@ -111,8 +114,9 @@ describe("OpenRouterPlugin", () => { catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) expect( - required(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))) - .enabled, + required( + yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")), + ).enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index 027f81d36d9..c958d139e46 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -44,7 +44,9 @@ describe("VercelPlugin", () => { expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( "HTTP-Referer", ) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty("X-Title") + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( + "X-Title", + ) }), ) diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 67a3e29cc1f..02ad579acb0 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -23,10 +23,7 @@ export const V2Command = effectCmd({ small: Object.fromEntries( yield* Effect.all( all.map((provider) => - Effect.map( - catalog.model.small(provider.id), - (model) => [provider.id, model?.id] as const, - ), + Effect.map(catalog.model.small(provider.id), (model) => [provider.id, model?.id] as const), ), { concurrency: "unbounded" }, ), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e2add116e0e..1c4fd8f5dfd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6,7 +6,6 @@ export type ClientOptions = { export type Event = | EventModelsDevRefreshed - | EventPluginAdded | EventIntegrationUpdated | EventCatalogUpdated | EventSessionCreated @@ -53,6 +52,7 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventFileEdited + | EventPluginAdded | EventPermissionV2Asked | EventPermissionV2Replied | EventReferenceUpdated @@ -737,13 +737,6 @@ export type GlobalEvent = { [key: string]: unknown } } - | { - id: string - type: "plugin.added" - properties: { - id: string - } - } | { id: string type: "integration.updated" @@ -1264,6 +1257,13 @@ export type GlobalEvent = { file: string } } + | { + id: string + type: "plugin.added" + properties: { + id: string + } + } | { id: string type: "permission.v2.asked" @@ -4003,7 +4003,7 @@ export type ModelV2Info = { } }> time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + released: number } cost: Array<{ tier?: { @@ -4228,14 +4228,6 @@ export type EventModelsDevRefreshed = { } } -export type EventPluginAdded = { - id: string - type: "plugin.added" - properties: { - id: string - } -} - export type EventIntegrationUpdated = { id: string type: "integration.updated" @@ -4802,6 +4794,14 @@ export type EventFileEdited = { } } +export type EventPluginAdded = { + id: string + type: "plugin.added" + properties: { + id: string + } +} + export type EventPermissionV2Asked = { id: string type: "permission.v2.asked" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b0cf1678c78..0b43276400c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14481,9 +14481,6 @@ { "$ref": "#/components/schemas/EventModels-devRefreshed" }, - { - "$ref": "#/components/schemas/EventPluginAdded" - }, { "$ref": "#/components/schemas/EventIntegrationUpdated" }, @@ -14622,6 +14619,9 @@ { "$ref": "#/components/schemas/EventFileEdited" }, + { + "$ref": "#/components/schemas/EventPluginAdded" + }, { "$ref": "#/components/schemas/EventPermissionV2Asked" }, @@ -16645,31 +16645,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["plugin.added"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, { "type": "object", "properties": { @@ -18453,6 +18428,31 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -26900,27 +26900,7 @@ "type": "object", "properties": { "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] + "type": "number" } }, "required": ["released"], @@ -27643,31 +27623,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventPluginAdded": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["plugin.added"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventIntegrationUpdated": { "type": "object", "properties": { @@ -29443,6 +29398,31 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventPluginAdded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventPermissionV2Asked": { "type": "object", "properties": { From 1b8bab3e35df893ab7914bce8f90c5e4f22e2ebe Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 20 Jun 2026 22:29:56 -0400 Subject: [PATCH 045/112] fix(ci): avoid Playwright Chromium install hang --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c36f41106c..aeeccc42fec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,7 +99,8 @@ jobs: - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "24" + # Playwright 1.59 hangs while extracting Chromium with Node 24.16. + node-version: "24.15" - name: Setup Bun uses: ./.github/actions/setup-bun From 8396395f1781d54d37bef8054bedd3a4b43d4174 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 21 Jun 2026 08:08:21 -0400 Subject: [PATCH 046/112] fix(stats): update defect schemas --- packages/stats/core/src/athena.ts | 2 +- packages/stats/core/src/database.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index a2be44ebb76..54037002f57 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -17,7 +17,7 @@ export type AthenaData = Record export class AthenaQueryError extends Schema.TaggedErrorClass()("AthenaQueryError", { message: Schema.String, queryExecutionId: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass()( diff --git a/packages/stats/core/src/database.ts b/packages/stats/core/src/database.ts index d265f82bf1e..9edb717bc71 100644 --- a/packages/stats/core/src/database.ts +++ b/packages/stats/core/src/database.ts @@ -45,14 +45,14 @@ export class DrizzleClient extends Context.Service()("@o } export class DatabaseError extends Schema.TaggedErrorClass()("DatabaseError", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export const catchDbError = Effect.mapError((cause) => DatabaseError.make({ cause })) export class MigrationError extends Schema.TaggedErrorClass()("MigrationError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export const migrate = Effect.fn("Database.migrate")(function* () { From ca006a2d206370365c10793a89da486d1a7497fc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 21 Jun 2026 12:21:25 +0000 Subject: [PATCH 047/112] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 86502338019..ef9387b8698 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=", - "aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=", - "aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=", - "x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y=" + "x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=", + "aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=", + "aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=", + "x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs=" } } From fb43c15f88ede60effcb95eba2698f95943d0147 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 21 Jun 2026 16:34:57 +0200 Subject: [PATCH 048/112] refactor(core): simplify event model (#33238) --- packages/core/src/event.ts | 309 ++++++++---------- packages/core/src/public/session.ts | 7 +- packages/core/src/session.ts | 14 +- packages/core/src/session/event.ts | 6 +- packages/core/src/session/input.ts | 42 +-- packages/core/src/session/projector.ts | 49 ++- packages/core/src/v1/session.ts | 2 +- packages/core/test/event.test.ts | 115 ++----- packages/core/test/session-create.test.ts | 6 +- packages/core/test/session-projector.test.ts | 130 +------- packages/core/test/session-prompt.test.ts | 70 +--- .../test/session-runner-tool-events.test.ts | 8 +- packages/core/test/session-runner.test.ts | 28 -- packages/opencode/src/event-v2-bridge.ts | 10 +- .../routes/instance/httpapi/groups/global.ts | 4 +- 15 files changed, 231 insertions(+), 569 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 7a33eedc40f..b483b280d75 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" -import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" +import { externalID, type ExternalID, withStatics } from "./schema" import { Identifier } from "./util/identifier" import { LayerNode } from "./effect/layer-node" import { isDeepStrictEqual } from "node:util" @@ -19,16 +19,9 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( ) export type ID = typeof ID.Type -/** - * Durable aggregate continuation position for embedded replay streams. - * TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead. - */ -export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor")) -export type Cursor = typeof Cursor.Type - export type Definition = { readonly type: Type - readonly sync?: { + readonly durable?: { readonly version: number readonly aggregate: string } @@ -41,20 +34,16 @@ export type Payload = { readonly id: ID readonly type: D["type"] readonly data: Data - /** Durable aggregate order, populated while synchronized events are projected. */ - readonly seq?: number - readonly version?: number + readonly durable?: { + readonly aggregateID: string + readonly seq: number + readonly version: number + } readonly location?: Location.Ref readonly metadata?: Record - /** Internal replay marker for projectors that own non-replicated operational state. */ - readonly replay?: boolean } -export type Projector = (event: Payload) => Effect.Effect -type AnyProjector = (event: Payload) => Effect.Effect -export type CommitGuard = (event: Payload) => Effect.Effect -export type Listener = (event: Payload) => Effect.Effect -export type Sync = (event: Payload) => Effect.Effect +export type Subscriber = (event: Payload) => Effect.Effect export type Unsubscribe = Effect.Effect export type SerializedEvent = { @@ -65,13 +54,8 @@ export type SerializedEvent = { readonly data: Record } -export type CursorEvent = { - readonly cursor: Cursor - readonly event: E -} - -export class InvalidSyncEventError extends Schema.TaggedErrorClass()( - "EventV2.InvalidSyncEvent", +export class InvalidDurableEventError extends Schema.TaggedErrorClass()( + "EventV2.InvalidDurableEvent", { type: Schema.String, message: Schema.String, @@ -83,19 +67,11 @@ export function versionedType(type: string, version: number) { } export const registry = new Map() -type SyncDefinition = Definition & { - readonly sync: NonNullable - readonly encode: (data: unknown) => unknown - readonly decode: (data: unknown) => unknown -} -const syncRegistry = new Map() - -// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services. -const syncCodec = (definition: Definition) => definition.data as Schema.Codec +const durableRegistry = new Map() export function define(input: { readonly type: Type - readonly sync?: { + readonly durable?: { readonly version: number readonly aggregate: string } @@ -106,28 +82,26 @@ export function define= existing.sync.version) { + if ( + input.durable === undefined || + existing?.durable === undefined || + input.durable.version >= existing.durable.version + ) { registry.set(input.type, definition) } - if (input.sync) - syncRegistry.set( - versionedType(input.type, input.sync.version), - Object.assign(definition, { - encode: Schema.encodeUnknownSync(syncCodec(definition)), - decode: Schema.decodeUnknownSync(syncCodec(definition)), - }) as SyncDefinition, - ) + if (input.durable) + durableRegistry.set(versionedType(input.type, input.durable.version), definition) return definition as Schema.Schema>>> & Definition> } @@ -140,7 +114,7 @@ export interface PublishOptions { readonly id?: ID readonly metadata?: Record readonly location?: Location.Ref - /** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */ + /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */ readonly commit?: (seq: number) => Effect.Effect } @@ -152,14 +126,13 @@ export interface Interface { ) => Effect.Effect> readonly subscribe: (definition: D) => Stream.Stream> readonly all: () => Stream.Stream - readonly aggregateEvents: (input: { + readonly durable: (input: { readonly aggregateID: string - readonly after?: Cursor - }) => Stream.Stream - readonly sync: (handler: Sync) => Effect.Effect - readonly listen: (listener: Listener) => Effect.Effect - readonly beforeCommit: (guard: CommitGuard) => Effect.Effect - readonly project: (definition: D, projector: Projector) => Effect.Effect + readonly after?: number + }) => Stream.Stream + /** @deprecated Use `all()` and consume the returned stream. */ + readonly listen: (listener: Subscriber) => Effect.Effect + readonly project: (definition: D, projector: Subscriber) => Effect.Effect readonly replay: ( event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, @@ -182,37 +155,37 @@ export const layerWith = (options?: LayerOptions) => Layer.effect( Service, Effect.gen(function* () { - const all = yield* PubSub.unbounded() - const synchronized = new Map>>() - const typed = new Map>() - const projectors = new Map() - const commitGuards = new Array() - const listeners = new Array() - const syncHandlers = new Array() + const pubsub = { + all: yield* PubSub.unbounded(), + durable: new Map>>(), + typed: new Map>(), + } + const projectors = new Map() + const listeners = new Array() const { db } = yield* Database.Service const getOrCreate = (definition: Definition) => Effect.gen(function* () { - const existing = typed.get(definition.type) + const existing = pubsub.typed.get(definition.type) if (existing) return existing - const pubsub = yield* PubSub.unbounded() - typed.set(definition.type, pubsub) - return pubsub + const created = yield* PubSub.unbounded() + pubsub.typed.set(definition.type, created) + return created }) yield* Effect.addFinalizer(() => Effect.gen(function* () { - yield* PubSub.shutdown(all) + yield* PubSub.shutdown(pubsub.all) yield* Effect.forEach( - synchronized.values(), + pubsub.durable.values(), (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), { discard: true }, ) - yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true }) + yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true }) }), ) - function commitSyncEvent( + function commitDurableEvent( event: Payload, input?: { readonly seq: number @@ -224,28 +197,20 @@ export const layerWith = (options?: LayerOptions) => ) { return Effect.gen(function* () { const definition = registry.get(event.type) - const sync = definition?.sync - if (sync) { - if (event.version !== sync.version) { - yield* Effect.die( - new InvalidSyncEventError({ - type: event.type, - message: `Expected event version ${sync.version}, got ${event.version}`, - }), - ) - } - const aggregateID = (event.data as Record)[sync.aggregate] + const durable = definition?.durable + if (durable) { + const aggregateID = (event.data as Record)[durable.aggregate] if (typeof aggregateID !== "string") { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, - message: `Expected string aggregate field ${sync.aggregate}`, + message: `Expected string aggregate field ${durable.aggregate}`, }), ) } else { if (input && input.aggregateID !== aggregateID) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`, }), @@ -265,12 +230,12 @@ export const layerWith = (options?: LayerOptions) => .get() .pipe(Effect.orDie) const latest = row?.seq ?? -1 - const encoded = syncRegistry - .get(versionedType(definition.type, sync.version))! - .encode(event.data) as Record + const encoded = Schema.encodeUnknownSync( + definition.data as Schema.Codec, + )(event.data) as Record if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, }), @@ -285,7 +250,7 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) if ( stored?.id === event.id && - stored.type === versionedType(definition.type, sync.version) && + stored.type === versionedType(definition.type, durable.version) && isDeepStrictEqual(stored.data, encoded) ) { if (input.ownerID && row?.ownerID == null) { @@ -299,7 +264,7 @@ export const layerWith = (options?: LayerOptions) => return } yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, }), @@ -311,7 +276,7 @@ export const layerWith = (options?: LayerOptions) => const seq = input?.seq ?? latest + 1 if (input && seq !== latest + 1) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`, }), @@ -325,16 +290,17 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) if (stored) yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, }), ) - for (const guard of commitGuards) { - yield* guard(event) - } + const committed = { + ...event, + durable: { aggregateID, seq, version: durable.version }, + } as Payload for (const projector of list) { - yield* projector({ ...event, seq } as Payload) + yield* projector(committed) } if (commit) yield* commit(seq) yield* db @@ -356,7 +322,7 @@ export const layerWith = (options?: LayerOptions) => id: event.id, aggregate_id: aggregateID, seq, - type: versionedType(definition.type, sync.version), + type: versionedType(definition.type, durable.version), data: encoded, }, ]) @@ -369,8 +335,8 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) if (committed) { yield* Effect.forEach( - synchronized.get(committed.aggregateID) ?? [], - (pubsub) => PubSub.publish(pubsub, undefined), + pubsub.durable.get(committed.aggregateID) ?? [], + (wake) => PubSub.publish(wake, undefined), { discard: true }, ) } @@ -384,19 +350,25 @@ export const layerWith = (options?: LayerOptions) => function publishEvent(event: Payload, commit?: PublishOptions["commit"]) { return Effect.gen(function* () { - const durable = registry.get(event.type)?.sync !== undefined - if (!durable && commit) + const definition = registry.get(event.type) + if (!definition?.durable && commit) return yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, - message: "Local commit hooks require a synchronized event", + message: "Local commit hooks require a durable event", }), ) - if (durable) { - const committed = yield* commitSyncEvent(event as Payload, undefined, commit) + if (definition?.durable) { + const committed = yield* commitDurableEvent(event as Payload, undefined, commit) if (committed) { - event = { ...event, seq: committed.seq } - yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) + event = { + ...event, + durable: { + aggregateID: committed.aggregateID, + seq: committed.seq, + version: definition.durable.version, + }, + } yield* notify(event as Payload, true) return event } @@ -406,12 +378,12 @@ export const layerWith = (options?: LayerOptions) => }) } - const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect) => + const observe = (event: Payload, observer: (event: Payload) => Effect.Effect) => Effect.suspend(() => observer(event)).pipe( Effect.catchCauseIf( (cause) => !Cause.hasInterrupts(cause), (cause) => - Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }), + Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }), ), ) @@ -419,12 +391,12 @@ export const layerWith = (options?: LayerOptions) => return Effect.gen(function* () { yield* Effect.forEach( listeners, - (listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)), + (listener) => (isolateListeners ? observe(event, listener) : listener(event)), { discard: true }, ) - const pubsub = typed.get(event.type) - if (pubsub) yield* PubSub.publish(pubsub, event) - yield* PubSub.publish(all, event) + const typed = pubsub.typed.get(event.type) + if (typed) yield* PubSub.publish(typed, event) + yield* PubSub.publish(pubsub.all, event) }) } @@ -441,7 +413,6 @@ export const layerWith = (options?: LayerOptions) => id: options?.id ?? ID.create(), ...(options?.metadata ? { metadata: options.metadata } : {}), type: definition.type, - ...(definition.sync === undefined ? {} : { version: definition.sync.version }), ...(location ? { location } : {}), data, } as Payload, @@ -455,27 +426,37 @@ export const layerWith = (options?: LayerOptions) => options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, ) { return Effect.gen(function* () { - const definition = syncRegistry.get(event.type) - if (!definition) { + const definition = durableRegistry.get(event.type) + if (!definition?.durable) { yield* Effect.die( - new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }), + new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), ) } else { const payload = { id: event.id, type: definition.type, - version: definition.sync.version, - data: definition.decode(event.data), - replay: true, + data: Schema.decodeUnknownSync( + definition.data as Schema.Codec, + )(event.data), } as Payload - const committed = yield* commitSyncEvent(payload, { + const committed = yield* commitDurableEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID, strictOwner: options?.strictOwner, }) if (committed && options?.publish) { - yield* notify({ ...payload, seq: committed.seq }, true) + yield* notify( + { + ...payload, + durable: { + aggregateID: committed.aggregateID, + seq: committed.seq, + version: definition.durable.version, + }, + }, + true, + ) } } }) @@ -490,7 +471,7 @@ export const layerWith = (options?: LayerOptions) => if (!source) return undefined if (events.some((event) => event.aggregateID !== source)) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: events[0]?.type ?? "unknown", message: "Replay events must belong to the same aggregate", }), @@ -501,7 +482,7 @@ export const layerWith = (options?: LayerOptions) => const seq = start + index if (event.seq !== seq) { yield* Effect.die( - new InvalidSyncEventError({ + new InvalidDurableEventError({ type: event.type, message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`, }), @@ -540,22 +521,18 @@ export const layerWith = (options?: LayerOptions) => Stream.map((event) => event as Payload), ) - const streamAll = (): Stream.Stream => Stream.fromPubSub(all) + const streamAll = (): Stream.Stream => Stream.fromPubSub(pubsub.all) - const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => { - const definition = syncRegistry.get(event.type) - if (!definition) { - throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }) + const decodeSerializedEvent = (event: SerializedEvent): Payload => { + const definition = durableRegistry.get(event.type) + if (!definition?.durable) { + throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) } return { - cursor: Cursor.make(event.seq), - event: { - id: event.id, - type: definition.type, - version: definition.sync.version, - seq: event.seq, - data: definition.decode(event.data), - }, + id: event.id, + type: definition.type, + durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, + data: Schema.decodeUnknownSync(definition.data as Schema.Codec)(event.data), } } @@ -583,43 +560,43 @@ export const layerWith = (options?: LayerOptions) => ), ) - const subscribeSynchronized = (aggregateID: string) => + const subscribeDurable = (aggregateID: string) => Effect.gen(function* () { - const pubsub = yield* PubSub.sliding(1) - const subscription = yield* PubSub.subscribe(pubsub) + const wake = yield* PubSub.sliding(1) + const subscription = yield* PubSub.subscribe(wake) yield* Effect.acquireRelease( Effect.sync(() => { - const pubsubs = synchronized.get(aggregateID) ?? new Set() - pubsubs.add(pubsub) - synchronized.set(aggregateID, pubsubs) + const wakes = pubsub.durable.get(aggregateID) ?? new Set() + wakes.add(wake) + pubsub.durable.set(aggregateID, wakes) }), () => Effect.sync(() => { - const pubsubs = synchronized.get(aggregateID) - pubsubs?.delete(pubsub) - if (pubsubs?.size === 0) synchronized.delete(aggregateID) - }).pipe(Effect.andThen(PubSub.shutdown(pubsub))), + const wakes = pubsub.durable.get(aggregateID) + wakes?.delete(wake) + if (wakes?.size === 0) pubsub.durable.delete(aggregateID) + }).pipe(Effect.andThen(PubSub.shutdown(wake))), ) return subscription }) - const streamEvents = (input: { + const durable = (input: { readonly aggregateID: string - readonly after?: Cursor - }): Stream.Stream => + readonly after?: number + }): Stream.Stream => Stream.unwrap( Effect.gen(function* () { - const synchronized = yield* subscribeSynchronized(input.aggregateID) - let cursor = input.after ?? -1 - const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe( + const wakes = yield* subscribeDurable(input.aggregateID) + let sequence = input.after ?? -1 + const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe( Effect.tap((events) => Effect.sync(() => { - cursor = events.at(-1)?.cursor ?? cursor + sequence = events.at(-1)?.durable?.seq ?? sequence }), ), ) const historical = yield* read - const live = Stream.fromSubscription(synchronized).pipe( + const live = Stream.fromSubscription(wakes).pipe( Stream.mapEffect(() => read), Stream.flattenIterable, ) @@ -627,7 +604,7 @@ export const layerWith = (options?: LayerOptions) => }), ) - const listen = (listener: Listener): Effect.Effect => + const listen = (listener: Subscriber): Effect.Effect => Effect.sync(() => { listeners.push(listener) return Effect.sync(() => { @@ -636,21 +613,7 @@ export const layerWith = (options?: LayerOptions) => }) }) - const sync = (handler: Sync): Effect.Effect => - Effect.sync(() => { - syncHandlers.push(handler) - return Effect.sync(() => { - const index = syncHandlers.indexOf(handler) - if (index >= 0) syncHandlers.splice(index, 1) - }) - }) - - const beforeCommit = (guard: CommitGuard): Effect.Effect => - Effect.sync(() => { - commitGuards.push(guard) - }) - - const project = (definition: D, projector: Projector): Effect.Effect => + const project = (definition: D, projector: Subscriber): Effect.Effect => Effect.sync(() => { const list = projectors.get(definition.type) ?? [] list.push((event) => projector(event as Payload)) @@ -661,10 +624,8 @@ export const layerWith = (options?: LayerOptions) => publish, subscribe, all: streamAll, - aggregateEvents: streamEvents, - sync, + durable, listen, - beforeCommit, project, replay, replayAll, diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts index 6c61aff3b6d..2610cec004e 100644 --- a/packages/core/src/public/session.ts +++ b/packages/core/src/public/session.ts @@ -1,7 +1,6 @@ export * as Session from "./session" import { Effect, Schema, Stream } from "effect" -import { EventV2 } from "../event" import { ModelV2 } from "../model" import { SessionV2 } from "../session" import { MessageDecodeError } from "../session/error" @@ -34,9 +33,7 @@ export type Delivery = SessionInput.Delivery export const ListInput = SessionV2.ListInput export type ListInput = SessionV2.ListInput -export const EventCursor = EventV2.Cursor -export type EventCursor = EventV2.Cursor -export type Event = EventV2.CursorEvent +export type Event = SessionEvent.DurableEvent export const NotFoundError = SessionV2.NotFoundError export type NotFoundError = SessionV2.NotFoundError @@ -99,7 +96,7 @@ export interface MessageInput { export interface EventsInput { readonly sessionID: ID - readonly after?: EventCursor + readonly after?: number } export interface Interface { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index d5163cf8839..be314e1f9f1 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -124,8 +124,8 @@ export interface Interface { ) => Effect.Effect readonly events: (input: { sessionID: SessionSchema.ID - after?: EventV2.Cursor - }) => Stream.Stream, NotFoundError> + after?: number + }) => Stream.Stream readonly switchAgent: (input: { sessionID: SessionSchema.ID agent: string @@ -339,11 +339,9 @@ export const layer = Layer.effect( Stream.unwrap( result .get(input.sessionID) - .pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))), + .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), ).pipe( - Stream.filter((event): event is EventV2.CursorEvent => - isDurableSessionEvent(event.event), - ), + Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event)), ), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( @@ -413,9 +411,9 @@ export const layer = Layer.effect( sessionID, timestamp: yield* DateTime.now, }) - if (event.seq === undefined) + if (event.durable === undefined) return yield* Effect.die("Interrupt request event is missing aggregate sequence") - yield* execution.interrupt(sessionID, event.seq) + yield* execution.interrupt(sessionID, event.durable.seq) }), ), ), diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 3472cc114a5..5eaf0371686 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -27,13 +27,13 @@ const Base = { } const options = { - sync: { + durable: { aggregate: "sessionID", version: 1, }, } as const const stepSettlementOptions = { - sync: { + durable: { aggregate: "sessionID", version: 2, }, @@ -456,7 +456,7 @@ export namespace Compaction { export const Ended = EventV2.define({ type: "session.next.compaction.ended", - sync: { aggregate: "sessionID", version: 2 }, + durable: { aggregate: "sessionID", version: 2 }, schema: { ...Base, messageID: SessionMessageID.ID, diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 041c629988a..f8bc2b0e6bb 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -74,11 +74,11 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( }) .pipe( Effect.flatMap((event) => - event.seq === undefined + event.durable === undefined ? Effect.die("Prompt admission event is missing aggregate sequence") : Effect.succeed( new Admitted({ - admittedSeq: event.seq, + admittedSeq: event.durable.seq, id: input.id, sessionID: input.sessionID, prompt: input.prompt, @@ -117,13 +117,6 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio readonly timeCreated: DateTime.Utc }, ) { - const message = yield* db - .select({ id: SessionMessageTable.id }) - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, input.id)) - .get() - .pipe(Effect.orDie) - if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id })) const stored = yield* db .insert(SessionInputTable) .values({ @@ -208,37 +201,6 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) -export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* ( - db: DatabaseService, - event: EventV2.Payload, -) { - if ( - Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) || - Schema.is(SessionEvent.PromptLifecycle.Promoted)(event) - ) - return - const id = reservedID(event) - if (id === undefined) return - const admitted = yield* db - .select({ id: SessionInputTable.id }) - .from(SessionInputTable) - .where(eq(SessionInputTable.id, id)) - .get() - .pipe(Effect.orDie) - if (admitted === undefined) return - return yield* Effect.die(new LifecycleConflict({ id })) -}) - -const reservedID = (event: EventV2.Payload) => { - if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID - if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID - if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID -} - export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* ( db: DatabaseService, input: { diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index caf63de78ac..b8945494f3e 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -115,7 +115,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) const updateMessage = (message: SessionMessage.Message) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -192,7 +192,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) { } function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -201,7 +201,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: id: SessionMessage.ID.make(id), session_id: event.data.sessionID, type, - seq: event.seq, + seq: event.durable.seq, time_created: DateTime.toEpochMillis(message.time.created), data, }) @@ -213,7 +213,6 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event)) yield* events.project(SessionV1.Event.Created, (event) => Effect.gen(function* () { const stored = yield* db @@ -331,7 +330,7 @@ export const layer = Layer.effectDiscard( }), ) yield* events.project(SessionEvent.AgentSwitched, (event) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") return db .update(SessionTable) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) @@ -340,7 +339,7 @@ export const layer = Layer.effectDiscard( .pipe( Effect.orDie, Effect.andThen(run(db, event)), - Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), + Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)), ) }) yield* events.project(SessionEvent.ModelSwitched, (event) => @@ -352,9 +351,9 @@ export const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie) yield* run(db, event) - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq) + if (event.durable === undefined) + return yield* Effect.die("Durable Session event is missing aggregate sequence") + yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq) }), ) yield* events.project(SessionEvent.Prompted, (event) => @@ -368,24 +367,24 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) if (existing) return yield* Effect.die(new PromptAlreadyProjected()) yield* run(db, event) - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) + return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionInput.projectLegacyPrompted(db, { id: messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, timeCreated: event.data.timestamp, - promotedSeq: event.seq, + promotedSeq: event.durable.seq, }) }), ) yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => Effect.gen(function* () { - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) + return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionInput.projectAdmitted(db, { - admittedSeq: event.seq, + admittedSeq: event.durable.seq, id: event.data.messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, @@ -396,8 +395,8 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => Effect.gen(function* () { - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) + return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* insertMessage( db, event, @@ -406,18 +405,14 @@ export const layer = Layer.effectDiscard( sessionID: event.data.sessionID, prompt: event.data.prompt, timeCreated: event.data.timeCreated, - promotedSeq: event.seq, + promotedSeq: event.durable.seq, }), ) }), ) yield* events.project(SessionEvent.InterruptRequested, () => Effect.void) - yield* events.project(SessionEvent.ContextUpdated, (event) => { - if (!event.replay || event.seq === undefined) return run(db, event) - return run(db, event).pipe( - Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), - ) - }) + // TODO: Reconstruct context epoch replacement state during replay without adding replay state to every EventV2 payload. + yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) @@ -436,9 +431,9 @@ export const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Ended, (event) => { - if (event.version === 1) return Effect.void - const seq = event.seq - if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable.version === 1) return Effect.void + const seq = event.durable.seq return Effect.gen(function* () { yield* run(db, event) yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq) diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index 34bb729683a..181ba9807d0 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -502,7 +502,7 @@ export type WithParts = { } const options = { - sync: { + durable: { aggregate: "sessionID", version: 1, }, diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index cd8ba69253f..c28d902acb0 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -30,7 +30,7 @@ const Message = EventV2.define({ const SyncMessage = EventV2.define({ type: "test.sync", - sync: { + durable: { version: 1, aggregate: "id", }, @@ -42,7 +42,7 @@ const SyncMessage = EventV2.define({ const SyncSent = EventV2.define({ type: "test.sent", - sync: { + durable: { version: 1, aggregate: "messageID", }, @@ -61,7 +61,7 @@ const GlobalMessage = EventV2.define({ const VersionedMessage = EventV2.define({ type: "test.versioned", - sync: { + durable: { version: 2, aggregate: "id", }, @@ -73,7 +73,7 @@ const VersionedMessage = EventV2.define({ const SyncTimestamp = EventV2.define({ type: "test.timestamp", - sync: { + durable: { version: 1, aggregate: "id", }, @@ -132,7 +132,7 @@ describe("EventV2", () => { const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" }) expect(event.type).toBe("test.versioned") - expect(event.version).toBe(2) + expect(event.durable?.version).toBe(2) }), ) @@ -146,12 +146,12 @@ describe("EventV2", () => { Effect.sync(() => { const latest = EventV2.define({ type: "test.out-of-order", - sync: { version: 2, aggregate: "id" }, + durable: { version: 2, aggregate: "id" }, schema: { id: Schema.String }, }) EventV2.define({ type: "test.out-of-order", - sync: { version: 1, aggregate: "id" }, + durable: { version: 1, aggregate: "id" }, schema: { id: Schema.String }, }) @@ -190,7 +190,7 @@ describe("EventV2", () => { }), ) - it.effect("commits local operational state inside a new synchronized event transaction", () => + it.effect("commits local operational state inside a new durable event transaction", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() @@ -207,7 +207,7 @@ describe("EventV2", () => { }), ) - it.effect("rolls back the synchronized event and projector when the local commit fails", () => + it.effect("rolls back the durable event and projector when the local commit fails", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -236,7 +236,7 @@ describe("EventV2", () => { const events = yield* EventV2.Service const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit) - expect(String(exit)).toContain("Local commit hooks require a synchronized event") + expect(String(exit)).toContain("Local commit hooks require a durable event") }), ) @@ -290,7 +290,6 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - yield* events.sync(() => Effect.die("sync defect")) yield* events.listen(() => { throw new Error("listener defect") }) @@ -303,7 +302,7 @@ describe("EventV2", () => { const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" }) expect(received).toEqual([SyncMessage.type]) - expect(event.seq).toBeNumber() + expect(event.durable?.seq).toBeNumber() }), ) @@ -336,49 +335,7 @@ describe("EventV2", () => { }), ) - it.effect("does not synchronize live-only events", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const synchronized = new Array() - const unsubscribe = yield* events.sync((event) => - Effect.sync(() => { - synchronized.push(event.type) - }), - ) - yield* Effect.addFinalizer(() => unsubscribe) - - yield* events.publish(Message, { text: "live only" }) - yield* events.publish(SyncMessage, { id: "one", text: "durable" }) - - expect(synchronized).toEqual([SyncMessage.type]) - }), - ) - - it.effect("synchronizes only after the durable event commits", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const { db } = yield* Database.Service - const synchronized = new Array() - yield* events.sync((event) => - db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.id, event.id)) - .get() - .pipe( - Effect.orDie, - Effect.map((row) => synchronized.push(row !== undefined)), - Effect.asVoid, - ), - ) - - yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" }) - - expect(synchronized).toEqual([true]) - }), - ) - - it.effect("inserts sync event rows on publish", () => + it.effect("inserts durable event rows on publish", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -398,7 +355,7 @@ describe("EventV2", () => { }), ) - it.effect("increments sync event seq per aggregate", () => + it.effect("increments durable event seq per aggregate", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -417,22 +374,22 @@ describe("EventV2", () => { }), ) - it.effect("replays durable aggregate events after a cursor and tails new events", () => + it.effect("replays durable aggregate events after a sequence and tails new events", () => Effect.gen(function* () { const events = yield* EventV2.Service const aggregateID = EventV2.ID.create() yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) const fiber = yield* events - .aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) }) + .durable({ aggregateID, after: 0 }) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* events.publish(SyncMessage, { id: aggregateID, text: "two" }) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([ - [EventV2.Cursor.make(1), { id: aggregateID, text: "one" }], - [EventV2.Cursor.make(2), { id: aggregateID, text: "two" }], + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [1, { id: aggregateID, text: "one" }], + [2, { id: aggregateID, text: "two" }], ]) }), ) @@ -443,19 +400,19 @@ describe("EventV2", () => { const aggregateID = EventV2.ID.create() yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) const fiber = yield* events - .aggregateEvents({ aggregateID }) + .durable({ aggregateID }) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) expect( Array.from(yield* Fiber.join(fiber)).map((event) => [ - event.cursor, - (event.event.data as { text: string }).text, + event.durable?.seq, + (event.data as { text: string }).text, ]), ).toEqual([ - [EventV2.Cursor.make(0), "zero"], - [EventV2.Cursor.make(1), "one"], + [0, "zero"], + [1, "one"], ]) }), ) @@ -477,7 +434,7 @@ describe("EventV2", () => { const events = yield* EventV2.Service const aggregateID = EventV2.ID.create() const fiber = yield* events - .aggregateEvents({ aggregateID }) + .durable({ aggregateID }) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Deferred.await(readStarted) @@ -485,8 +442,8 @@ describe("EventV2", () => { yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" }) yield* Deferred.succeed(continueRead, undefined) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([ - [EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }], + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [0, { id: aggregateID, text: "during handoff" }], ]) }).pipe(Effect.provide(Layer.mergeAll(database, eventLayer))) }), @@ -498,7 +455,7 @@ describe("EventV2", () => { const aggregateID = EventV2.ID.create() const count = 64 const fiber = yield* events - .aggregateEvents({ aggregateID }) + .durable({ aggregateID }) .pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -506,9 +463,9 @@ describe("EventV2", () => { yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) }) } - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual( + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual( Array.from({ length: count }, (_, index) => [ - EventV2.Cursor.make(index), + index, { id: aggregateID, text: String(index) }, ]), ) @@ -520,14 +477,14 @@ describe("EventV2", () => { const events = yield* EventV2.Service const aggregateID = EventV2.ID.create() const fiber = yield* events - .aggregateEvents({ aggregateID }) + .durable({ aggregateID }) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* events.publish(Message, { text: "live only" }) yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" }) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type]) + expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([SyncMessage.type]) }), ) @@ -550,7 +507,7 @@ describe("EventV2", () => { }), ) - it.effect("replays sync events through projectors", () => + it.effect("replays durable events through projectors", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() @@ -706,7 +663,7 @@ describe("EventV2", () => { }) .pipe(Effect.exit) - expect(String(exit)).toContain("Unknown sync event type") + expect(String(exit)).toContain("Unknown durable event type") }), ) @@ -843,7 +800,7 @@ describe("EventV2", () => { const replayed = { id: published.id, type: EventV2.versionedType(SyncMessage.type, 1), - seq: published.seq!, + seq: published.durable!.seq, aggregateID, data: published.data, } @@ -988,7 +945,7 @@ describe("EventV2", () => { yield* events.replay(replayed, { publish: true }) yield* events.replay(replayed, { publish: true }) - expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }]) + expect(received).toMatchObject([{ id: replayed.id, durable: { seq: 0, version: 1 }, data: replayed.data }]) }), ) @@ -1110,7 +1067,7 @@ describe("EventV2", () => { }), ) - it.effect("remove clears sync event sequence", () => + it.effect("remove clears durable event sequence", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 3551ec52f3c..471e86ff923 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -220,8 +220,8 @@ describe("SessionV2.create", () => { expect( Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ - { cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } }, - { cursor: 2, event: { type: "session.next.prompt.promoted" } }, + { durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } }, + { durable: { seq: 2 }, type: "session.next.prompt.promoted" }, ]) }), ) @@ -355,7 +355,7 @@ describe("SessionV2.create", () => { expect(yield* session.get(created.id)).toMatchObject({ model }) expect( Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }]) + ).toMatchObject([{ type: "session.next.model.switched", data: { model } }]) }), ) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index f84f60f3082..df9ac731b01 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -162,7 +162,7 @@ describe("SessionProjector", () => { expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ promoted_seq: event.seq }) + ).toMatchObject({ promoted_seq: event.durable?.seq }) }), ) @@ -334,134 +334,6 @@ describe("SessionProjector", () => { }), ) - it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_conflict") - yield* SessionInput.admit(db, events, { - id, - sessionID, - prompt: new Prompt({ text: "admitted" }), - delivery: "steer", - }) - - const exit = yield* events - .publish(SessionEvent.Prompted, { - sessionID, - messageID: id, - timestamp: created, - prompt: new Prompt({ text: "different" }), - delivery: "steer", - }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ promoted_seq: null }) - }), - ) - - it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_conflict") - yield* SessionInput.admit(db, events, { - id, - sessionID, - prompt: new Prompt({ text: "admitted" }), - delivery: "steer", - }) - - const exit = yield* events - .publish(SessionEvent.Step.Started, { - sessionID, - timestamp: created, - assistantMessageID: id, - agent: "build", - model, - }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), - ).toBeUndefined() - }), - ) - - it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_delivery_conflict") - const prompt = new Prompt({ text: "admitted" }) - yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" }) - - const exit = yield* events - .publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ delivery: "queue", promoted_seq: null }) - }), - ) - it.effect("does not revive a stale incomplete in-memory assistant projection", () => Effect.gen(function* () { const stale = new SessionMessage.Assistant({ diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index d663cb715bb..5b6cc1ce152 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -177,7 +177,7 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("streams durable Session events after an aggregate cursor", () => + it.effect("streams durable Session events after an aggregate sequence", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -191,17 +191,17 @@ describe("SessionV2.prompt", () => { yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) const streamed = Array.from(yield* Fiber.join(fiber)) - expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([ - [EventV2.Cursor.make(0), "session.next.prompt.admitted"], - [EventV2.Cursor.make(1), "session.next.prompt.admitted"], - [EventV2.Cursor.make(2), "session.next.prompt.promoted"], - [EventV2.Cursor.make(3), "session.next.prompt.promoted"], + expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([ + [0, "session.next.prompt.admitted"], + [1, "session.next.prompt.admitted"], + [2, "session.next.prompt.promoted"], + [3, "session.next.prompt.promoted"], ]) expect( Array.from( - yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect), - ).map((event) => [event.cursor, event.event.type]), - ).toEqual([[EventV2.Cursor.make(1), "session.next.prompt.admitted"]]) + yield* session.events({ sessionID, after: streamed[0]!.durable?.seq }).pipe(Stream.take(1), Stream.runCollect), + ).map((event) => [event.durable?.seq, event.type]), + ).toEqual([[1, "session.next.prompt.admitted"]]) }), ) @@ -472,58 +472,6 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("rejects an input ID already used by a durable non-prompt event", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* events.publish(SessionEvent.Synthetic, { - sessionID, - messageID, - timestamp: yield* DateTime.now, - text: "Collision", - }) - - const failure = yield* session - .prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false }) - .pipe(Effect.flip) - - expect(failure._tag).toBe("Session.PromptConflictError") - expect(yield* admitted(messageID)).toBeUndefined() - }), - ) - - it.effect("rejects a durable event ID reserved by an admitted prompt without poisoning promotion", () => - Effect.gen(function* () { - yield* setup - const { db } = yield* Database.Service - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Reserved prompt" }) - yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) - - const failure = yield* events - .publish(SessionEvent.Synthetic, { - sessionID, - messageID, - timestamp: yield* DateTime.now, - text: "Conflicting synthetic", - }) - .pipe(Effect.catchDefect(Effect.succeed)) - - expect(String(failure)).toContain("SessionInput.LifecycleConflict") - expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq") - expect(yield* session.messages({ sessionID })).toEqual([]) - - yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) - - expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) - expect(yield* session.messages({ sessionID })).toMatchObject([ - { id: messageID, type: "user", text: "Reserved prompt" }, - ]) - }), - ) - it.effect("rejects reuse of one globally unique message ID across sessions", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 3d4a858cbbd..f2d18cfa2d2 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -19,17 +19,17 @@ const capture = () => { Effect.sync(() => { const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload published.push({ - type: definition.sync ? EventV2.versionedType(definition.type, definition.sync.version) : definition.type, + type: definition.durable + ? EventV2.versionedType(definition.type, definition.durable.version) + : definition.type, data, }) return event }), subscribe: () => Stream.empty, all: () => Stream.empty, - aggregateEvents: () => Stream.empty, - sync: () => Effect.succeed(Effect.void), + durable: () => Stream.empty, listen: () => Effect.succeed(Effect.void), - beforeCommit: () => Effect.void, project: () => Effect.void, replay: () => Effect.void, replayAll: () => Effect.succeed(undefined), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0cc27508558..393f5526ad8 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1355,34 +1355,6 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("replays retained context projections while replacement is pending", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, - }) - - yield* replaySessionProjection(sessionID) - systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) - yield* session.resume(sessionID) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context"]) - }), - ) - it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () => Effect.gen(function* () { yield* setup diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 14a8053f978..4cef7311dcb 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -45,9 +45,9 @@ export const layer = Layer.effect( workspace: workspaceID, payload: { id: event.id, type: event.type, properties: event.data }, }) - const sync = EventV2.registry.get(event.type)?.sync - if (sync === undefined || event.seq === undefined || event.version === undefined) return - const aggregateID = (event.data as Record)[sync.aggregate] + const durable = EventV2.registry.get(event.type)?.durable + if (durable === undefined || event.durable === undefined) return + const aggregateID = (event.data as Record)[durable.aggregate] if (typeof aggregateID !== "string") return GlobalBus.emit("event", { directory: event.location?.directory ?? ctx?.directory, @@ -57,8 +57,8 @@ export const layer = Layer.effect( type: "sync", syncEvent: { id: event.id, - type: EventV2.versionedType(event.type, event.version), - seq: event.seq, + type: EventV2.versionedType(event.type, event.durable.version), + seq: event.durable.seq, aggregateID, data: event.data, }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 87556a1ad61..3e13154867d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -16,13 +16,13 @@ const GlobalHealth = Schema.Struct({ const SyncEventSchemas = EventV2.registry .values() .flatMap((definition) => { - if (!definition.sync) return [] + if (!definition.durable) return [] return [ Schema.Struct({ type: Schema.Literal("sync"), id: EventV2.ID, syncEvent: Schema.Struct({ - type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)), + type: Schema.Literal(EventV2.versionedType(definition.type, definition.durable.version)), id: EventV2.ID, seq: Schema.Finite, aggregateID: Schema.String, From 82d9cab48d6bb4b9ec36a89da1bea6f8af689be9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 21 Jun 2026 14:36:19 +0000 Subject: [PATCH 049/112] chore: generate --- packages/core/src/event.ts | 22 +++++++--------------- packages/core/src/session.ts | 4 +--- packages/core/src/session/projector.ts | 12 ++++-------- packages/core/test/event.test.ts | 17 ++++------------- packages/core/test/session-prompt.test.ts | 4 +++- 5 files changed, 19 insertions(+), 40 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index b483b280d75..32aaeae6995 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -100,8 +100,7 @@ export function define>>> & Definition> } @@ -126,10 +125,7 @@ export interface Interface { ) => Effect.Effect> readonly subscribe: (definition: D) => Stream.Stream> readonly all: () => Stream.Stream - readonly durable: (input: { - readonly aggregateID: string - readonly after?: number - }) => Stream.Stream + readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream /** @deprecated Use `all()` and consume the returned stream. */ readonly listen: (listener: Subscriber) => Effect.Effect readonly project: (definition: D, projector: Subscriber) => Effect.Effect @@ -382,8 +378,7 @@ export const layerWith = (options?: LayerOptions) => Effect.suspend(() => observer(event)).pipe( Effect.catchCauseIf( (cause) => !Cause.hasInterrupts(cause), - (cause) => - Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }), + (cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }), ), ) @@ -435,9 +430,9 @@ export const layerWith = (options?: LayerOptions) => const payload = { id: event.id, type: definition.type, - data: Schema.decodeUnknownSync( - definition.data as Schema.Codec, - )(event.data), + data: Schema.decodeUnknownSync(definition.data as Schema.Codec)( + event.data, + ), } as Payload const committed = yield* commitDurableEvent(payload, { seq: event.seq, @@ -580,10 +575,7 @@ export const layerWith = (options?: LayerOptions) => return subscription }) - const durable = (input: { - readonly aggregateID: string - readonly after?: number - }): Stream.Stream => + const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream => Stream.unwrap( Effect.gen(function* () { const wakes = yield* subscribeDurable(input.aggregateID) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index be314e1f9f1..b5a3bf48618 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -340,9 +340,7 @@ export const layer = Layer.effect( result .get(input.sessionID) .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), - ).pipe( - Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event)), - ), + ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index b8945494f3e..bffe4e74c6a 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -351,8 +351,7 @@ export const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie) yield* run(db, event) - if (event.durable === undefined) - return yield* Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq) }), ) @@ -367,8 +366,7 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) if (existing) return yield* Effect.die(new PromptAlreadyProjected()) yield* run(db, event) - if (event.durable === undefined) - return yield* Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionInput.projectLegacyPrompted(db, { id: messageID, sessionID: event.data.sessionID, @@ -381,8 +379,7 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => Effect.gen(function* () { - if (event.durable === undefined) - return yield* Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionInput.projectAdmitted(db, { admittedSeq: event.durable.seq, id: event.data.messageID, @@ -395,8 +392,7 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => Effect.gen(function* () { - if (event.durable === undefined) - return yield* Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* insertMessage( db, event, diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index c28d902acb0..fb5e195e2a8 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -399,9 +399,7 @@ describe("EventV2", () => { const events = yield* EventV2.Service const aggregateID = EventV2.ID.create() yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) - const fiber = yield* events - .durable({ aggregateID }) - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) @@ -433,9 +431,7 @@ describe("EventV2", () => { yield* Effect.gen(function* () { const events = yield* EventV2.Service const aggregateID = EventV2.ID.create() - const fiber = yield* events - .durable({ aggregateID }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Deferred.await(readStarted) pause = false @@ -464,10 +460,7 @@ describe("EventV2", () => { } expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual( - Array.from({ length: count }, (_, index) => [ - index, - { id: aggregateID, text: String(index) }, - ]), + Array.from({ length: count }, (_, index) => [index, { id: aggregateID, text: String(index) }]), ) }), ) @@ -476,9 +469,7 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const aggregateID = EventV2.ID.create() - const fiber = yield* events - .durable({ aggregateID }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* events.publish(Message, { text: "live only" }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 5b6cc1ce152..b2aec228e55 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -199,7 +199,9 @@ describe("SessionV2.prompt", () => { ]) expect( Array.from( - yield* session.events({ sessionID, after: streamed[0]!.durable?.seq }).pipe(Stream.take(1), Stream.runCollect), + yield* session + .events({ sessionID, after: streamed[0]!.durable?.seq }) + .pipe(Stream.take(1), Stream.runCollect), ).map((event) => [event.durable?.seq, event.type]), ).toEqual([[1, "session.next.prompt.admitted"]]) }), From 823d327401ba93d24174c9feb50b5dbe4f60f646 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 21 Jun 2026 19:50:14 +0200 Subject: [PATCH 050/112] fix(core): handle missing read paths (#33255) --- packages/core/src/tool/read.ts | 4 +-- packages/core/test/tool-read.test.ts | 39 ++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 64f02d813fe..22ec57b9418 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard( const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute)) return yield* Effect.die(new Error("Path escapes the allowed read root")) - const real = yield* fs.realPath(absolute).pipe(Effect.orDie) - const root = yield* fs.realPath(selected).pipe(Effect.orDie) + const real = yield* fs.realPath(absolute) + const root = yield* fs.realPath(selected) if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the allowed read root")) const resource = path.relative(root, real).replaceAll("\\", "/") || "." diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 605a1e17da5..1d9553c77bc 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import { Effect, Exit, Layer, PlatformError } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -18,6 +18,8 @@ import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const assertions: PermissionV2.AssertInput[] = [] +const missingPath = "__missing_read_target__.txt" +const missingAbsolutePath = `${process.cwd()}/${missingPath}` const readCalls: { input: AbsolutePath page: ReadToolFileSystem.PageInput @@ -70,7 +72,24 @@ const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => const image = Image.layer.pipe(Layer.provide(config)) const testFileSystem = Layer.effect( FSUtil.Service, - FSUtil.Service.use((fs) => Effect.succeed(FSUtil.Service.of({ ...fs, realPath: (path) => Effect.succeed(path) }))), + FSUtil.Service.use((fs) => + Effect.succeed( + FSUtil.Service.of({ + ...fs, + realPath: (path) => + path === missingAbsolutePath + ? Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(path), + }), + ), + ), ).pipe(Layer.provide(FSUtil.defaultLayer)) const infrastructure = Layer.mergeAll( testFileSystem, @@ -453,6 +472,22 @@ describe("ReadTool", () => { }), ) + it.effect("returns missing paths as model-visible tool failures", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } }, + }), + ).toEqual({ type: "error", value: `Unable to read ${missingPath}` }) + expect(assertions).toEqual([]) + expect(readCalls).toEqual([]) + }), + ) + it.effect("lists a bounded directory page through read", () => Effect.gen(function* () { resolvedType = "directory" From 69f1ec22e3309b845406b24563bbf2bf2c267bf6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 21 Jun 2026 21:37:25 +0200 Subject: [PATCH 051/112] fix(core): bound web tool failures (#33259) --- packages/core/src/tool/http-body.ts | 30 ++++++++++++++++++++ packages/core/src/tool/webfetch.ts | 34 +++++++++-------------- packages/core/src/tool/websearch.ts | 11 +++++--- packages/core/test/tool-webfetch.test.ts | 19 +++++++++++++ packages/core/test/tool-websearch.test.ts | 29 +++++++++++++++++-- 5 files changed, 95 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/tool/http-body.ts diff --git a/packages/core/src/tool/http-body.ts b/packages/core/src/tool/http-body.ts new file mode 100644 index 00000000000..7cb534a444f --- /dev/null +++ b/packages/core/src/tool/http-body.ts @@ -0,0 +1,30 @@ +import { Effect, Stream } from "effect" +import { HttpClientResponse } from "effect/unstable/http" + +export const collectBoundedResponseBody = ( + response: HttpClientResponse.HttpClientResponse, + maximumBytes: number, + tooLarge: () => Error, +) => + Effect.gen(function* () { + const contentLength = response.headers["content-length"] + const parsedSize = contentLength ? Number.parseInt(contentLength, 10) : undefined + const declaredSize = + parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined + if (declaredSize !== undefined && declaredSize > maximumBytes) return yield* Effect.fail(tooLarge()) + let body = Buffer.allocUnsafe(Math.min(maximumBytes, declaredSize || 64 * 1024)) + let size = 0 + yield* Stream.runForEach(response.stream, (chunk) => { + if (chunk.byteLength === 0) return Effect.void + if (size + chunk.byteLength > maximumBytes) return Effect.fail(tooLarge()) + if (size + chunk.byteLength > body.byteLength) { + const grown = Buffer.allocUnsafe(Math.min(maximumBytes, Math.max(size + chunk.byteLength, body.byteLength * 2))) + body.copy(grown, 0, 0, size) + body = grown + } + body.set(chunk, size) + size += chunk.byteLength + return Effect.void + }) + return body.subarray(0, size) + }) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 1e209e50086..2ce0868d991 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,11 +1,12 @@ export * as WebFetchTool from "./webfetch" import { ToolFailure } from "@opencode-ai/llm" -import { Duration, Effect, Layer, Schema, Stream } from "effect" +import { Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Parser } from "htmlparser2" import TurndownService from "turndown" import { PermissionV2 } from "../permission" +import { collectBoundedResponseBody } from "./http-body" import { Tool } from "./tool" import { Tools } from "./tools" @@ -86,24 +87,11 @@ const execute = (http: HttpClient.HttpClient, url: string, format: Format, userA http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)) const collectBody = (response: HttpClientResponse.HttpClientResponse) => - Effect.gen(function* () { - const contentLength = response.headers["content-length"] - if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) { - return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) - } - const chunks: Uint8Array[] = [] - let size = 0 - yield* Stream.runForEach(response.stream, (chunk) => - Effect.gen(function* () { - size += chunk.byteLength - if (size > MAX_RESPONSE_BYTES) - return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) - chunks.push(chunk) - return undefined - }), - ) - return Buffer.concat(chunks, size) - }) + collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`), + ) const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "" const isImageAttachment = (mime: string) => @@ -171,12 +159,16 @@ export const layer = Layer.effectDiscard( orElse: () => Effect.fail(new Error("Request timed out")), }), ) - const content = convert(new TextDecoder().decode(body), contentType, input.format) + const content = new TextDecoder().decode(body) + const output = yield* Effect.try({ + try: () => convert(content, contentType, input.format), + catch: (error) => error, + }) return { url: input.url, contentType, format: input.format, - output: content, + output, } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }), diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index cea19c17e8f..14c10377ee0 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -9,6 +9,7 @@ import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" import { Tool } from "./tool" import { Tools } from "./tools" +import { collectBoundedResponseBody } from "./http-body" import { checksum } from "../util/encode" export const name = "websearch" @@ -164,10 +165,12 @@ const callMcp = ( ) return yield* Effect.gen(function* () { const response = yield* HttpClient.filterStatusOk(http).execute(request) - const body = yield* response.text - if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) - return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`)) - return yield* parseResponse(body) + const body = yield* collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), + ) + return yield* parseResponse(body.toString("utf8")) }).pipe( Effect.timeoutOrElse({ duration: Duration.seconds(25), diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index b2541e3e21f..5a856ffaf42 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -176,6 +176,25 @@ describe("WebFetchTool registration", () => { }), ) + it.effect("returns an error result when HTML-to-Markdown conversion throws", () => + Effect.gen(function* () { + reset() + respond = () => + Effect.succeed( + new Response("
".repeat(10_000) + "content" + "
".repeat(10_000), { + headers: { "content-type": "text/html" }, + }), + ) + const registry = yield* ToolRegistry.Service + const url = "https://1.1.1.1/deep-html" + + expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({ + type: "error", + value: `Unable to fetch ${url}`, + }) + }), + ) + it.effect("rejects declared and streamed oversized bodies", () => Effect.gen(function* () { reset() diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index dc38a9c3502..9715dd5c7f2 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -66,8 +66,14 @@ interface Request { const requests: Request[] = [] const assertions: PermissionV2.AssertInput[] = [] let responseBody = payload("search results") +let makeResponse = () => new Response(responseBody, { status: 200 }) let config: WebSearchTool.Config = { enableExa: false, enableParallel: false } +beforeEach(() => { + responseBody = payload("search results") + makeResponse = () => new Response(responseBody, { status: 200 }) +}) + const http = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -78,7 +84,7 @@ const http = Layer.succeed( headers: request.headers, body: JSON.parse(new TextDecoder().decode(request.body.body)), }) - return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 })) + return HttpClientResponse.fromWeb(request, makeResponse()) }), ), ) @@ -270,7 +276,22 @@ describe("WebSearchTool registration", () => { Effect.gen(function* () { requests.length = 0 assertions.length = 0 - responseBody = "x".repeat(WebSearchTool.MAX_RESPONSE_BYTES + 1) + let chunksRead = 0 + let cancelled = false + makeResponse = () => + new Response( + new ReadableStream({ + pull(controller) { + chunksRead++ + if (chunksRead === 10) throw new Error("response was not stopped at the byte limit") + controller.enqueue(new Uint8Array(64 * 1024)) + }, + cancel() { + cancelled = true + }, + }), + { status: 200 }, + ) config = { provider: "exa", enableExa: false, enableParallel: false } const registry = yield* ToolRegistry.Service @@ -281,6 +302,8 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, }), ).toEqual({ type: "error", value: "Unable to search the web for too much" }) + expect(chunksRead).toBeLessThan(10) + expect(cancelled).toBe(true) }), ) }) From 4c6750d46461c82f5d3dc19f16e3cbb164e10c8f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:53:46 -0500 Subject: [PATCH 052/112] fix(stats): unblock stats sync --- packages/stats/core/src/athena.ts | 58 +++++++++++++-------- packages/stats/core/src/database.ts | 35 +++++++++---- packages/stats/core/src/domain/inference.ts | 5 ++ packages/stats/server/src/ingest.ts | 29 +++++++---- 4 files changed, 83 insertions(+), 44 deletions(-) diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index 54037002f57..50c356afa56 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -5,28 +5,36 @@ import { StartQueryExecutionCommand, type Row, } from "@aws-sdk/client-athena" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer } from "effect" import * as Context from "effect/Context" import { Resource } from "sst/resource" -const ATHENA_MAX_POLL_ATTEMPTS = 60 +const ATHENA_MAX_POLL_ATTEMPTS = 300 const ATHENA_PAGE_SIZE = 1000 export type AthenaData = Record -export class AthenaQueryError extends Schema.TaggedErrorClass()("AthenaQueryError", { - message: Schema.String, - queryExecutionId: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect()), -}) {} +export class AthenaQueryError extends Error { + readonly _tag = "AthenaQueryError" + readonly queryExecutionId?: string -export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass()( - "AthenaQueryTimeoutError", - { - message: Schema.String, - queryExecutionId: Schema.String, - }, -) {} + constructor(input: { message: string; queryExecutionId?: string; cause?: unknown }) { + super(input.message, { cause: input.cause }) + this.name = "AthenaQueryError" + this.queryExecutionId = input.queryExecutionId + } +} + +export class AthenaQueryTimeoutError extends Error { + readonly _tag = "AthenaQueryTimeoutError" + readonly queryExecutionId: string + + constructor(input: { message: string; queryExecutionId: string }) { + super(input.message) + this.name = "AthenaQueryTimeoutError" + this.queryExecutionId = input.queryExecutionId + } +} export declare namespace Athena { export interface Service { @@ -57,7 +65,7 @@ export class Athena extends Context.Service()("@opencode }) const queryExecutionId = started.QueryExecutionId if (!queryExecutionId) - return yield* new AthenaQueryError({ message: "Athena did not return a query execution id" }) + return yield* Effect.fail(new AthenaQueryError({ message: "Athena did not return a query execution id" })) yield* poll(client, queryExecutionId) return yield* results(client, queryExecutionId) @@ -87,16 +95,20 @@ const poll: ( if (status?.State === "SUCCEEDED") return if (status?.State === "FAILED" || status?.State === "CANCELLED") - return yield* new AthenaQueryError({ - message: `Athena stats query ${status.State.toLowerCase()}: ${status.StateChangeReason ?? "unknown reason"}`, - queryExecutionId, - }) + return yield* Effect.fail( + new AthenaQueryError({ + message: `Athena stats query ${status.State.toLowerCase()}: ${status.StateChangeReason ?? "unknown reason"}`, + queryExecutionId, + }), + ) if (attempt >= ATHENA_MAX_POLL_ATTEMPTS - 1) - return yield* new AthenaQueryTimeoutError({ - message: `Athena stats query ${queryExecutionId} did not complete`, - queryExecutionId, - }) + return yield* Effect.fail( + new AthenaQueryTimeoutError({ + message: `Athena stats query ${queryExecutionId} did not complete`, + queryExecutionId, + }), + ) return yield* poll(client, queryExecutionId, attempt + 1) }) diff --git a/packages/stats/core/src/database.ts b/packages/stats/core/src/database.ts index 9edb717bc71..2d55ee7f8eb 100644 --- a/packages/stats/core/src/database.ts +++ b/packages/stats/core/src/database.ts @@ -44,16 +44,29 @@ export class DrizzleClient extends Context.Service()("@o ) } -export class DatabaseError extends Schema.TaggedErrorClass()("DatabaseError", { - cause: Schema.Defect(), -}) {} +export class DatabaseError extends Error { + readonly _tag = "DatabaseError" + + constructor(input: { cause: unknown }) { + super("Database operation failed", { cause: input.cause }) + this.name = "DatabaseError" + } + + static make(input: { cause: unknown }) { + return new DatabaseError(input) + } +} export const catchDbError = Effect.mapError((cause) => DatabaseError.make({ cause })) -export class MigrationError extends Schema.TaggedErrorClass()("MigrationError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} +export class MigrationError extends Error { + readonly _tag = "MigrationError" + + constructor(input: { message: string; cause?: unknown }) { + super(input.message, { cause: input.cause }) + this.name = "MigrationError" + } +} export const migrate = Effect.fn("Database.migrate")(function* () { const settings = yield* DatabaseConfig @@ -68,9 +81,11 @@ export const migrate = Effect.fn("Database.migrate")(function* () { catch: (cause) => new MigrationError({ message: "Failed to apply database migrations", cause }), }) if (result) - return yield* new MigrationError({ - message: `Failed to initialize database migrations: ${result.exitCode}`, - }) + return yield* Effect.fail( + new MigrationError({ + message: `Failed to initialize database migrations: ${result.exitCode}`, + }), + ) yield* Effect.logInfo("database migrations complete").pipe( Effect.annotateLogs({ migrationsDir: settings.migrationsDir }), ) diff --git a/packages/stats/core/src/domain/inference.ts b/packages/stats/core/src/domain/inference.ts index a7e4c0037e8..3fc97aba339 100644 --- a/packages/stats/core/src/domain/inference.ts +++ b/packages/stats/core/src/domain/inference.ts @@ -17,6 +17,8 @@ export type StatDimension = "model" | "provider" | "geo" | "geo_model" export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: StatDimension) { const periodStartValue = sqlString(periodStart.toISOString()) const periodEndValue = sqlString(periodEnd.toISOString()) + const periodStartDateValue = sqlString(periodStart.toISOString().slice(0, 10)) + const periodEndDateValue = sqlString(periodEnd.toISOString().slice(0, 10)) const sourceTable = [Resource.InferenceEvent.catalog, Resource.InferenceEvent.database, Resource.InferenceEvent.table] .map(sqlIdentifier) .join(".") @@ -95,6 +97,9 @@ WITH normalized AS ( WHERE event_type = 'completions' AND model IS NOT NULL AND model <> '' + AND source = 'lite' + AND event_date >= ${periodStartDateValue} + AND event_date <= ${periodEndDateValue} AND event_timestamp >= ${periodStartValue} AND event_timestamp < ${periodEndValue} ), filtered AS ( diff --git a/packages/stats/server/src/ingest.ts b/packages/stats/server/src/ingest.ts index 763742d9c99..eda662d9896 100644 --- a/packages/stats/server/src/ingest.ts +++ b/packages/stats/server/src/ingest.ts @@ -1,6 +1,6 @@ import { Buffer } from "node:buffer" import { FirehoseClient, PutRecordBatchCommand } from "@aws-sdk/client-firehose" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer } from "effect" import * as Context from "effect/Context" import { Resource } from "sst/resource" @@ -12,11 +12,16 @@ type IngestEvent = Record type LakeRoute = { database: string; table: string } type FirehoseRecord = { Data: Uint8Array } -export class IngestError extends Schema.TaggedErrorClass()("IngestError", { - message: Schema.String, - failed: Schema.Number, - cause: Schema.optional(Schema.Defect()), -}) {} +export class IngestError extends Error { + readonly _tag = "IngestError" + readonly failed: number + + constructor(input: { message: string; failed: number; cause?: unknown }) { + super(input.message, { cause: input.cause }) + this.name = "IngestError" + this.failed = input.failed + } +} export declare namespace Ingest { export interface Service { @@ -37,10 +42,12 @@ export class Ingest extends Context.Service()("@opencode yield* Effect.logWarning( `lake ingest rejected ${JSON.stringify({ records: counts.records, unsupported: counts.unsupported })}`, ) - return yield* new IngestError({ - message: "Unsupported lake event type", - failed: counts.unsupported, - }) + return yield* Effect.fail( + new IngestError({ + message: "Unsupported lake event type", + failed: counts.unsupported, + }), + ) } if (counts.records === 0) return { records: 0 } @@ -66,7 +73,7 @@ export class Ingest extends Context.Service()("@opencode if (failed > 0) { yield* Effect.logWarning(`lake ingest incomplete ${JSON.stringify({ records: counts.records, failed })}`) - return yield* new IngestError({ message: "Failed to ingest all lake records", failed }) + return yield* Effect.fail(new IngestError({ message: "Failed to ingest all lake records", failed })) } yield* Effect.logInfo(`lake ingest complete ${JSON.stringify({ records: counts.records, batches })}`) From ff837fe949bd19726c2de12bfae8516b71fa1ac7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 21 Jun 2026 22:12:42 +0200 Subject: [PATCH 053/112] fix(core): handle read file failures (#33260) --- packages/core/src/tool/read-filesystem.ts | 176 ++++++++++++------ packages/core/src/tool/read.ts | 2 +- .../core/test/tool-read-filesystem.test.ts | 117 ++++++++++++ packages/core/test/tool-read.test.ts | 39 ++-- 4 files changed, 263 insertions(+), 71 deletions(-) create mode 100644 packages/core/test/tool-read-filesystem.test.ts diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index c27bdae6dee..ef0c4df179e 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -13,23 +13,61 @@ export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024 const MAX_LINE_LENGTH = 2_000 const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` -export class BinaryFileError extends Error { - constructor(readonly resource: string) { - super(`Cannot read binary file: ${resource}`) - this.name = "BinaryFileError" +export class BinaryFileError extends Schema.TaggedErrorClass()("ReadTool.BinaryFileError", { + resource: Schema.String, +}) { + override get message() { + return `Cannot read binary file: ${this.resource}` } } -export class MediaIngestLimitError extends Error { - constructor( - readonly resource: string, - readonly maximumBytes: number, - ) { - super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`) - this.name = "MediaIngestLimitError" +export class MediaIngestLimitError extends Schema.TaggedErrorClass()( + "ReadTool.MediaIngestLimitError", + { + resource: Schema.String, + maximumBytes: Schema.Number, + }, +) { + override get message() { + return `Media exceeds ${this.maximumBytes} byte ingestion limit: ${this.resource}` } } +export class MalformedUtf8Error extends Schema.TaggedErrorClass()("ReadTool.MalformedUtf8Error", { + resource: Schema.String, +}) { + override get message() { + return `File is not valid UTF-8: ${this.resource}` + } +} + +export class OffsetOutOfRangeError extends Schema.TaggedErrorClass()( + "ReadTool.OffsetOutOfRangeError", + { offset: Schema.Number }, +) { + override get message() { + return `Offset ${this.offset} is out of range` + } +} + +export class PathKindError extends Schema.TaggedErrorClass()("ReadTool.PathKindError", { + resource: Schema.String, + expected: Schema.Literals(["a file", "a file or directory"]), +}) { + override get message() { + return `Path is not ${this.expected}: ${this.resource}` + } +} + +export type InspectError = FSUtil.Error | PathKindError +export type ReadError = + | FSUtil.Error + | BinaryFileError + | MediaIngestLimitError + | MalformedUtf8Error + | OffsetOutOfRangeError + | PathKindError + export const PageInput = Schema.Struct({ offset: PositiveInt.pipe(Schema.optional), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), @@ -52,13 +90,13 @@ export class ListPage extends Schema.Class("ReadTool.ListPage")({ }) {} export interface Interface { - readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory"> + readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError> readonly read: ( path: AbsolutePath, resource: string, page?: PageInput, - ) => Effect.Effect - readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect + ) => Effect.Effect + readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/ReadToolFileSystem") {} @@ -111,11 +149,21 @@ const binary = (resource: string, bytes: Uint8Array) => { } return nonPrintable / bytes.length > 0.3 } +const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) => + Effect.try({ + try: () => decoder.decode(bytes, { stream: bytes !== undefined }), + catch: (error) => { + if (error instanceof TypeError) return new MalformedUtf8Error({ resource }) + throw error + }, + }) +const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) => + bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes) export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) { - const info = yield* fs.stat(input).pipe(Effect.orDie) + const info = yield* fs.stat(input) const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined - if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) + if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" })) return type }) @@ -125,32 +173,30 @@ export const read = Effect.fn("ReadTool.read")(function* ( resource: string, page: PageInput = {}, ) { - const real = yield* fs.realPath(input).pipe(Effect.orDie) + const real = yield* fs.realPath(input) return yield* Effect.scoped( Effect.gen(function* () { - const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const file = yield* fs.open(real, { flag: "r" }) + const info = yield* file.stat + if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" })) const first = Option.getOrElse( - yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie), + yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)), () => new Uint8Array(), ) const mime = imageMime(first) if (mime) { if (info.size > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) const chunks = [first] let total = first.length while (total <= MAX_MEDIA_INGEST_BYTES) { - const chunk = yield* file - .readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) - .pipe(Effect.orDie) + const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) if (Option.isNone(chunk)) break chunks.push(chunk.value) total += chunk.value.length } if (total > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) return { uri: pathToFileURL(real).href, name: path.basename(real), @@ -162,19 +208,19 @@ export const read = Effect.fn("ReadTool.read")(function* ( mime, } } - if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first)) - return yield* Effect.die(new BinaryFileError(resource)) + if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase())) + return yield* Effect.fail(new BinaryFileError({ resource })) const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined if (!paged) { + if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource })) const decoder = new TextDecoder("utf-8", { fatal: true }) - const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))] + const text = [yield* decodeUtf8(resource, decoder, first)] while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + const chunk = yield* file.readAlloc(64 * 1024) if (Option.isNone(chunk)) break - if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource)) - text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true }))) + text.push(yield* decodeChunk(resource, decoder, chunk.value)) } - text.push(yield* Effect.sync(() => decoder.decode())) + text.push(yield* decodeUtf8(resource, decoder)) return { uri: pathToFileURL(real).href, name: path.basename(real), @@ -191,34 +237,29 @@ export const read = Effect.fn("ReadTool.read")(function* ( let discard = false let line = 1 let bytes = 0 - let found = false - let truncated = false let next: number | undefined const append = (input: string) => { if (line < offset) { line++ - return + return true } if (lines.length >= limit || bytes >= MAX_READ_BYTES) { - truncated = true - next ??= line++ - return + next = line + return false } - found = true const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) if (bytes + size > MAX_READ_BYTES) { - truncated = true - next ??= line++ - return + next = line + return false } lines.push(text) bytes += size line++ + return true } - const consume = (chunk: Uint8Array) => { - if (chunk.includes(0)) throw new BinaryFileError(resource) - let text = decoder.decode(chunk, { stream: true }) + const consume = (input: string) => { + let text = input while (true) { const index = text.indexOf("\n") if (index === -1) { @@ -235,25 +276,44 @@ export const read = Effect.fn("ReadTool.read")(function* ( pending = "" discard = false text = text.slice(index + 1) - append(current.endsWith("\r") ? current.slice(0, -1) : current) + if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false } + return true } - yield* Effect.sync(() => consume(first)) - while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { + let start = 0 + while (start < chunk.length) { + if (lines.length >= limit || bytes >= MAX_READ_BYTES) { + next = line + return false + } + const newline = chunk.indexOf(10, start) + const end = newline === -1 ? chunk.length : newline + 1 + const segment = chunk.subarray(start, end) + if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource })) + if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false + start = end + } + return true + }) + let done = !(yield* consumeChunk(first)) + while (!done) { + const chunk = yield* file.readAlloc(64 * 1024) if (Option.isNone(chunk)) break - yield* Effect.sync(() => consume(chunk.value)) + done = !(yield* consumeChunk(chunk.value)) } - const tail = yield* Effect.sync(() => decoder.decode()) - if (!discard) pending += tail - if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) - if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`)) + if (!done) { + const tail = yield* decodeUtf8(resource, decoder) + if (!discard) pending += tail + if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) + } + if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset })) return new TextPage({ type: "text-page", content: lines.join("\n"), mime: FSUtil.mimeType(real), offset, - truncated, + truncated: next !== undefined, ...(next === undefined ? {} : { next }), }) }), @@ -261,8 +321,8 @@ export const read = Effect.fn("ReadTool.read")(function* ( }) export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { - const real = yield* fs.realPath(input).pipe(Effect.orDie) - const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie) + const real = yield* fs.realPath(input) + const items = yield* fs.readDirectoryEntries(real) const offset = page.offset ?? 1 const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) const entries = yield* Effect.forEach( diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 22ec57b9418..2635e4653ff 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -83,7 +83,7 @@ export const layer = Layer.effectDiscard( .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) } if ("encoding" in content && content.encoding === "base64") - return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource)) + return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return content }).pipe( Effect.mapError((error) => { diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts new file mode 100644 index 00000000000..2bc17541634 --- /dev/null +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -0,0 +1,117 @@ +import { describe, expect } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import path from "path" +import { Effect, FileSystem, Layer } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" +import { testEffect } from "./lib/effect" + +const it = testEffect(FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))) +const fixture = Effect.gen(function* () { + const fs = yield* FSUtil.Service + const files = yield* FileSystem.FileSystem + const directory = yield* files.makeTempDirectoryScoped() + return { fs, files, directory } +}) + +describe("ReadToolFileSystem", () => { + it.effect("fails with a typed filesystem error when a resolved file disappears", () => + Effect.gen(function* () { + const { fs, directory } = yield* fixture + const file = path.join(directory, "missing.txt") + + const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip) + + expect(error).toMatchObject({ _tag: "PlatformError" }) + }), + ) + + it.effect("fails when a file becomes the wrong path kind", () => + Effect.gen(function* () { + const { fs, directory } = yield* fixture + + const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError) + }), + ) + + it.effect("fails with a typed filesystem error when directory listing fails", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "file.txt") + yield* files.writeFileString(file, "hello") + + const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip) + + expect(error).toBeInstanceOf(FSUtil.FileSystemError) + if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries") + }), + ) + + it.effect("reports binary and malformed UTF-8 content as typed errors", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const binary = path.join(directory, "archive.dat") + const malformed = path.join(directory, "malformed.txt") + yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3)) + const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97) + malformedContent[64 * 1024] = 0x80 + yield* files.writeFile(malformed, malformedContent) + + const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip) + const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip) + + expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError) + expect(binaryError.message).toBe("Cannot read binary file: archive.dat") + expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error) + }), + ) + + it.effect("reports out-of-range pagination as a typed error", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "short.txt") + yield* files.writeFileString(file, "one\n") + + const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError) + expect(error.message).toBe("Offset 2 is out of range") + }), + ) + + it.effect("stops reading after the requested page is complete", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const prefix = new TextEncoder().encode("one\n") + for (const [name, trailing] of [ + ["malformed.txt", 0x80], + ["nul.txt", 0], + ] as const) { + const file = path.join(directory, name) + yield* files.writeFile(file, Uint8Array.from([...prefix, trailing])) + + const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 }) + + expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 }) + } + }), + ) + + it.effect("preserves the media ingestion limit message", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "oversized.png") + yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) + yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1) + + const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError) + expect(error.message).toBe( + `Media exceeds ${ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES} byte ingestion limit: oversized.png`, + ) + }), + ) +}) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 1d9553c77bc..c85b7e35913 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -34,7 +34,7 @@ let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = { encoding: "utf8", mime: "text/plain", } -let readFailure: unknown +let readFailure: ReadToolFileSystem.ReadError | undefined let configEntries: Config.Entry[] = [] const reader = Layer.succeed( ReadToolFileSystem.Service, @@ -42,7 +42,7 @@ const reader = Layer.succeed( inspect: () => (resolveFailure === undefined ? Effect.succeed(resolvedType) : Effect.die(resolveFailure)), read: (input, _resource, page = {}) => { readCalls.push({ input, page }) - if (readFailure !== undefined) return Effect.die(readFailure) + if (readFailure !== undefined) return Effect.fail(readFailure) return Effect.succeed(readResult) }, list: (_path, input = {}) => @@ -431,9 +431,32 @@ describe("ReadTool", () => { }), ) + it.effect("returns expected filesystem failures to the model", () => + Effect.gen(function* () { + readFailure = new ReadToolFileSystem.BinaryFileError({ resource: "archive.dat" }) + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-binary", + name: "read", + input: { path: "archive.dat", offset: 2, limit: 1 }, + }, + }), + ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) + expect(readCalls).toEqual([ + { input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } }, + ]) + }), + ) + it.effect("preserves unexpected filesystem defects", () => Effect.gen(function* () { - readFailure = new ReadToolFileSystem.BinaryFileError("archive.dat") + resolveFailure = new Error("unexpected") const registry = yield* ToolRegistry.Service expect( @@ -441,18 +464,10 @@ describe("ReadTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { - type: "tool-call", - id: "call-binary", - name: "read", - input: { path: "archive.dat", offset: 2, limit: 1 }, - }, + call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } }, }).pipe(Effect.exit), ), ).toBe(true) - expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } }, - ]) }), ) From 49593c1ec41deab3730861f1842e2835cd8dfe98 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 21 Jun 2026 22:22:09 +0200 Subject: [PATCH 054/112] fix(core): settle interrupted assistant steps (#33266) --- packages/core/src/session/runner/llm.ts | 11 ++----- .../src/session/runner/publish-llm-event.ts | 29 +++++++++++----- .../core/src/session/runner/to-llm-message.ts | 11 ++++++- .../core/test/session-runner-message.test.ts | 33 +++++++++++++++++++ packages/core/test/session-runner.test.ts | 2 ++ 5 files changed, 69 insertions(+), 17 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 233a4aa4d82..5d84e985a62 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -305,14 +305,7 @@ export const layer = Layer.effect( const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) - yield* withPublication( - events.publish(SessionEvent.Step.Failed, { - sessionID: session.id, - timestamp: yield* DateTime.now, - assistantMessageID: yield* publisher.startAssistant(), - error: { type: "unknown", message: llmFailure.reason.message }, - }), - ) + yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) } if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) @@ -327,6 +320,8 @@ export const layer = Layer.effect( ) { yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) + if (publisher.hasActiveAssistant()) + yield* withPublication(publisher.failAssistant("Provider turn interrupted")) } if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { const failure = Cause.squash(settled.cause) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 5390a26e3b8..b412edba0db 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -65,11 +65,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) >() const timestamp = DateTime.now let assistantMessageID: SessionMessage.ID | undefined + let assistantActive = false + let assistantFailed = false let providerFailed = false const startAssistant = Effect.fnUntraced(function* () { if (assistantMessageID !== undefined) return assistantMessageID assistantMessageID = SessionMessage.ID.create() + assistantActive = true yield* events.publish(SessionEvent.Step.Started, { ...input, assistantMessageID, @@ -190,6 +193,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* flushFragments() }) + const failAssistant = Effect.fnUntraced(function* (message: string) { + if (assistantFailed) return + yield* flush() + const assistantMessageID = yield* startAssistant() + assistantActive = false + assistantFailed = true + yield* events.publish(SessionEvent.Step.Failed, { + sessionID: input.sessionID, + timestamp: yield* timestamp, + assistantMessageID, + error: { type: "unknown", message }, + }) + }) + const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( message: string, hostedOnly = false, @@ -375,6 +392,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "step-finish": yield* flush() + assistantActive = false yield* events.publish(SessionEvent.Step.Ended, { sessionID: input.sessionID, timestamp: yield* timestamp, @@ -388,13 +406,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return case "provider-error": providerFailed = true - yield* flush() - yield* events.publish(SessionEvent.Step.Failed, { - sessionID: input.sessionID, - timestamp: yield* timestamp, - assistantMessageID: yield* startAssistant(), - error: { type: "unknown", message: event.message }, - }) + yield* failAssistant(event.message) return } }) @@ -402,10 +414,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return { publish, flush, + failAssistant, failUnsettledTools, + hasActiveAssistant: () => assistantActive, hasAssistantStarted: () => assistantMessageID !== undefined, hasProviderError: () => providerFailed, - startAssistant, assistantMessageID: assistantMessageIDForTool, } } diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index ae36f205b17..f0ce7eef7f1 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -82,12 +82,21 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) return item.provider?.executed === true && result ? [call, result] : [call] }) + const meaningful = content.filter((part) => { + if (part.type === "text") return part.text !== "" + if (part.type !== "reasoning") return true + return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) + }) const results = message.content .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) .filter((message) => message !== undefined) .map(Message.tool) - return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results] + if (meaningful.length === 0) return results + return [ + Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }), + ...results, + ] } function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 708fd9e7f8e..cc515742dda 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -14,6 +14,39 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { + test("omits empty assistant turns", () => { + const assistant = (value: string, content: SessionMessage.Assistant["content"]) => + new SessionMessage.Assistant({ + id: id(value), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content, + time: { created, completed: created }, + }) + const messages = toLLMMessages( + [ + assistant("empty", []), + assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "empty", text: "" })]), + assistant("empty-reasoning", [ + new SessionMessage.AssistantReasoning({ type: "reasoning", id: "empty-reasoning", text: "" }), + ]), + assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]), + assistant("reasoning", [ + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: "reasoning", + text: "", + providerMetadata: { anthropic: { signature: "sig_1" } }, + }), + ]), + ], + model, + ) + + expect(messages.map((message) => message.id)).toEqual([id("text"), id("reasoning")]) + }) + test("maps every top-level V2 Session message type", () => { const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const messages = toLLMMessages( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 393f5526ad8..eb5ccb277df 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -547,6 +547,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "user", text: prompt }, { type: "assistant", + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } From 7d204b5b57216359d1a948cb38472a61757619eb Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:40:22 -0500 Subject: [PATCH 055/112] feat(stats): show model unique users --- packages/stats/app/src/routes/[lab]/[model].tsx | 7 ++++++- packages/stats/core/src/domain/home.ts | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index a068ed0054f..0632dd2360a 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -330,7 +330,7 @@ function CatalogDatum(props: { label: string; value: string }) { function ModelOverview(props: { data: StatsModelData | null }) { return (
- + (
+ 0 ? Math.round(current.totalTokens / current.sessions) : 0, From 06dae383f794fbdadf9c7027717bff8dcb321a60 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 21 Jun 2026 23:42:07 +0000 Subject: [PATCH 056/112] chore: generate --- packages/stats/app/src/routes/[lab]/[model].tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 0632dd2360a..223218fcbbb 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -340,11 +340,7 @@ function ModelOverview(props: { data: StatsModelData | null }) { {(data) => (
- + Date: Sun, 21 Jun 2026 22:40:12 -0500 Subject: [PATCH 057/112] fix(tui): render skill load errors inline (#33298) --- packages/tui/src/component/dialog-skill.tsx | 46 ++++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index fa675f7a74d..e962a6e7c3e 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -1,7 +1,10 @@ +import { TextAttributes } from "@opentui/core" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" -import { createResource, createMemo } from "solid-js" +import { createResource, createMemo, createSignal } from "solid-js" import { useDialog } from "../ui/dialog" import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { errorMessage } from "../util/error" export type DialogSkillProps = { onSelect: (skill: string) => void @@ -10,14 +13,27 @@ export type DialogSkillProps = { export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() + const { theme } = useTheme() dialog.setSize("large") - const [skills] = createResource(async () => { - const result = await sdk.client.app.skills() - return result.data ?? [] - }) + const [loadError, setLoadError] = createSignal() + + const [skills] = createResource(() => + sdk.client.app + .skills({}, { throwOnError: true }) + .then((result) => result.data ?? []) + // Catch so the rejected resource never reaches the memo below: reading + // skills() in an errored state re-throws and tears down the dialog. + .catch((error) => { + setLoadError(error) + return undefined + }), + ) + + const showError = createMemo(() => Boolean(loadError())) const options = createMemo[]>(() => { + if (showError()) return [] const list = skills() ?? [] const maxWidth = Math.max(0, ...list.map((s) => s.name.length)) return list.map((skill) => ({ @@ -32,5 +48,23 @@ export function DialogSkill(props: DialogSkillProps) { })) }) - return + return ( + + + Could not load skills + + {errorMessage(loadError())} + + ) : undefined + } + /> + ) } From 35b3fc85d091594427a5344e2ad95128b62453b1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 21 Jun 2026 23:59:01 -0400 Subject: [PATCH 058/112] feat(core): expose session switching endpoints --- packages/core/src/session.ts | 12 ++- packages/core/test/session-create.test.ts | 29 ++++++- .../test/server/httpapi-exercise/index.ts | 18 ++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 86 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 78 +++++++++++++++++ packages/server/src/groups/session.ts | 32 +++++++ packages/server/src/handlers/session.ts | 32 +++++++ 7 files changed, 283 insertions(+), 4 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b5a3bf48618..90736fb6510 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -129,7 +129,7 @@ export interface Interface { readonly switchAgent: (input: { sessionID: SessionSchema.ID agent: string - }) => Effect.Effect + }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -376,8 +376,14 @@ export const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* () { return yield* new OperationUnavailableError({ operation: "skill" }) }), - switchAgent: Effect.fn("V2Session.switchAgent")(function* () { - return yield* new OperationUnavailableError({ operation: "switchAgent" }) + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: input.agent, + }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { yield* result.get(input.sessionID) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 471e86ff923..96c7c9bd212 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -336,7 +336,34 @@ describe("SessionV2.create", () => { expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill") - expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent") + }), + ) + + it.effect("switches the selected agent through the durable Session event", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + + yield* session.switchAgent({ sessionID: created.id, agent: "plan" }) + + expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) + expect( + Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), + ).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }]) + }), + ) + + it.effect("rejects an agent switch for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const missing = SessionV2.ID.make("ses_missing_agent_switch") + + expect( + yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe( + Effect.flip, + Effect.map((error) => error._tag), + ), + ).toBe("Session.NotFoundError") }), ) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index b1f7bd8b725..5febf9cb202 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -955,6 +955,24 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, data(object)), + http.protected + .post("/api/session/{sessionID}/agent", "v2.session.switchAgent") + .seeded((ctx) => ctx.session({ title: "Switch agent" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/agent", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { agent: "plan" }, + })) + .status(204, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/model", "v2.session.switchModel") + .seeded((ctx) => ctx.session({ title: "Switch model" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/model", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { model: { providerID: "opencode", id: "big-pickle" } }, + })) + .status(204, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7c1c9108d95..7bf19806e36 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -353,6 +353,10 @@ import type { V2SessionQuestionRejectResponses, V2SessionQuestionReplyErrors, V2SessionQuestionReplyResponses, + V2SessionSwitchAgentErrors, + V2SessionSwitchAgentResponses, + V2SessionSwitchModelErrors, + V2SessionSwitchModelResponses, V2SessionWaitErrors, V2SessionWaitResponses, V2SkillListErrors, @@ -5337,6 +5341,88 @@ export class Session3 extends HeyApiClient { }) } + /** + * Switch session agent + * + * Switch the agent used by subsequent session activity. + */ + public switchAgent( + parameters: { + sessionID: string + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchAgentResponses, + V2SessionSwitchAgentErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/agent", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Switch session model + * + * Switch the model used by subsequent session activity. + */ + public switchModel( + parameters: { + sessionID: string + model?: { + id: string + providerID: string + variant?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchModelResponses, + V2SessionSwitchModelErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1c4fd8f5dfd..d2c9e298948 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9521,6 +9521,84 @@ export type V2SessionGetResponses = { export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses] +export type V2SessionSwitchAgentData = { + body: { + agent: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/agent" +} + +export type V2SessionSwitchAgentErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors] + +export type V2SessionSwitchAgentResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V2SessionSwitchAgentResponses] + +export type V2SessionSwitchModelData = { + body: { + model: { + id: string + providerID: string + variant?: string + } + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/model" +} + +export type V2SessionSwitchModelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors] + +export type V2SessionSwitchModelResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses] + export type V2SessionPromptData = { body: { id?: string diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index a208de82f03..ac4418d39f7 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -140,6 +140,38 @@ export const SessionGroup = HttpApiGroup.make("server.session") }), ), ) + .add( + HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { + params: { sessionID: SessionV2.ID }, + payload: Schema.Struct({ agent: AgentV2.ID }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(SessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchAgent", + summary: "Switch session agent", + description: "Switch the agent used by subsequent session activity.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { + params: { sessionID: SessionV2.ID }, + payload: Schema.Struct({ model: ModelV2.Ref }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(SessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchModel", + summary: "Switch session model", + description: "Switch the model used by subsequent session activity.", + }), + ), + ) .add( HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionV2.ID }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 66383cfbfff..1fe860e5284 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -92,6 +92,38 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.switchAgent", + Effect.fn(function* (ctx) { + yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.switchModel", + Effect.fn(function* (ctx) { + yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) { From cdc6d01c5a9d45e867f254d940b4cff4d3270d25 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 22 Jun 2026 04:00:59 +0000 Subject: [PATCH 059/112] chore: generate --- packages/core/src/session.ts | 5 +- packages/sdk/openapi.json | 183 +++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 4 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 90736fb6510..7454e0fa752 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -126,10 +126,7 @@ export interface Interface { sessionID: SessionSchema.ID after?: number }) => Stream.Stream - readonly switchAgent: (input: { - sessionID: SessionSchema.ID - agent: string - }) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0b43276400c..04165386c72 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10325,6 +10325,189 @@ ] } }, + "/api/session/{sessionID}/agent": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchAgent", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the agent used by subsequent session activity.", + "summary": "Switch session agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + } + }, + "required": ["agent"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.switchAgent({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/model": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchModel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the model used by subsequent session activity.", + "summary": "Switch session model", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["model"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.switchModel({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": ["sessions"], From 2bb431104200904a452c02f7ab6c42061e8ac5a5 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 22 Jun 2026 00:15:34 -0400 Subject: [PATCH 060/112] refactor(core): simplify integration test fixtures (#33292) --- packages/core/bunfig.toml | 2 + packages/core/src/catalog.ts | 11 +- packages/core/src/credential.ts | 28 +-- packages/core/src/credential/sql.ts | 2 +- packages/core/src/database/schema.gen.ts | 32 +-- packages/core/src/filesystem.ts | 2 +- packages/core/src/filesystem/search.ts | 2 +- packages/core/src/integration.ts | 60 ++--- packages/core/src/session/runner/model.ts | 4 +- packages/core/test/catalog.test.ts | 45 ++-- packages/core/test/config/provider.test.ts | 70 ++++-- packages/core/test/credential.test.ts | 26 +- packages/core/test/event.test.ts | 5 +- .../core/test/filesystem/filesystem.test.ts | 2 +- packages/core/test/filesystem/search.test.ts | 4 +- packages/core/test/fixture/location.ts | 14 ++ packages/core/test/integration.test.ts | 193 +++++--------- packages/core/test/location-layer.test.ts | 3 +- packages/core/test/move-session.test.ts | 31 ++- packages/core/test/permission.test.ts | 18 +- packages/core/test/plugin/fixture.ts | 48 ++++ packages/core/test/plugin/models-dev.test.ts | 6 +- .../core/test/plugin/provider-alibaba.test.ts | 49 +++- .../plugin/provider-amazon-bedrock.test.ts | 236 ++++++++++++++---- .../test/plugin/provider-anthropic.test.ts | 74 +++--- .../provider-azure-cognitive-services.test.ts | 126 ++++++++-- .../core/test/plugin/provider-azure.test.ts | 156 +++++++++--- .../test/plugin/provider-cerebras.test.ts | 64 ++++- .../provider-cloudflare-ai-gateway.test.ts | 149 +++++++++-- .../provider-cloudflare-workers-ai.test.ts | 114 +++++++-- .../core/test/plugin/provider-cohere.test.ts | 64 ++++- .../test/plugin/provider-deepinfra.test.ts | 101 ++++++-- .../core/test/plugin/provider-dynamic.test.ts | 101 +++++--- .../core/test/plugin/provider-gateway.test.ts | 58 ++++- .../plugin/provider-github-copilot.test.ts | 141 +++++++++-- .../core/test/plugin/provider-gitlab.test.ts | 98 ++++++-- .../provider-google-vertex-anthropic.test.ts | 135 +++++++--- .../plugin/provider-google-vertex.test.ts | 129 +++++++--- .../core/test/plugin/provider-google.test.ts | 74 ++++-- .../core/test/plugin/provider-groq.test.ts | 98 +++++--- packages/core/test/plugin/provider-helper.ts | 189 -------------- .../core/test/plugin/provider-kilo.test.ts | 92 +++---- .../test/plugin/provider-llmgateway.test.ts | 75 +++--- .../core/test/plugin/provider-mistral.test.ts | 92 ++++--- .../core/test/plugin/provider-nvidia.test.ts | 81 +++--- .../plugin/provider-openai-compatible.test.ts | 63 +++-- .../core/test/plugin/provider-openai.test.ts | 94 +++++-- .../test/plugin/provider-opencode.test.ts | 200 +++++++++------ .../test/plugin/provider-openrouter.test.ts | 95 ++++--- .../test/plugin/provider-perplexity.test.ts | 98 +++++--- .../test/plugin/provider-sap-ai-core.test.ts | 74 ++++-- .../plugin/provider-snowflake-cortex.test.ts | 117 ++++++--- .../test/plugin/provider-togetherai.test.ts | 90 +++++-- .../core/test/plugin/provider-venice.test.ts | 85 +++++-- .../core/test/plugin/provider-vercel.test.ts | 64 +++-- .../core/test/plugin/provider-xai.test.ts | 95 ++++--- .../core/test/plugin/provider-zenmux.test.ts | 88 ++++--- packages/core/test/preload.ts | 1 + packages/core/test/project-copy.test.ts | 13 +- .../core/test/project-directories.test.ts | 5 +- packages/core/test/project.test.ts | 13 +- packages/core/test/question.test.ts | 6 +- packages/core/test/session-create.test.ts | 20 +- packages/core/test/session-projector.test.ts | 11 +- packages/core/test/session-prompt.test.ts | 21 +- .../core/test/session-runner-model.test.ts | 2 +- .../core/test/session-runner-recorded.test.ts | 24 +- packages/core/test/session-runner.test.ts | 26 +- packages/core/test/session-todo.test.ts | 5 +- .../core/test/session-tool-progress.test.ts | 5 +- packages/core/test/tool-edit.test.ts | 2 +- .../core/test/tool-read-filesystem.test.ts | 2 +- packages/core/test/tool-read.test.ts | 21 +- packages/core/test/tool-todowrite.test.ts | 13 +- packages/core/test/tool-write.test.ts | 2 +- turbo.json | 4 + 76 files changed, 2864 insertions(+), 1599 deletions(-) create mode 100644 packages/core/bunfig.toml create mode 100644 packages/core/test/plugin/fixture.ts delete mode 100644 packages/core/test/plugin/provider-helper.ts create mode 100644 packages/core/test/preload.ts diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 00000000000..786a3774447 --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/preload.ts"] diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index d32f9366811..ed982cb6d7f 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -69,10 +69,10 @@ export const layer = Layer.effect( const policy = yield* Policy.Service const integrations = yield* Integration.Service - const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { + const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { if (provider.disabled) return false if (typeof provider.request.body.apiKey === "string") return true - if (connected) return true + if (integration?.connections.length) return true return !integration } @@ -183,13 +183,8 @@ export const layer = Layer.effect( available: Effect.fn("CatalogV2.provider.available")(function* () { const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) - const connections = yield* integrations.connection.list() return (yield* result.provider.all()).filter((provider) => - available( - provider, - active.get(Integration.ID.make(provider.id)), - connections.has(Integration.ID.make(provider.id)), - ), + available(provider, active.get(Integration.ID.make(provider.id))), ) }), }, diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 937ec4a51a7..b01bb1d1fd8 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -29,33 +29,33 @@ export class Key extends Schema.Class("Credential.Key")({ metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) {} -export const Info = Schema.Union([OAuth, Key]) +export const Value = Schema.Union([OAuth, Key]) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Credential.Info" }) -export type Info = Schema.Schema.Type + .annotate({ identifier: "Credential.Value" }) +export type Value = Schema.Schema.Type -export class Stored extends Schema.Class("Credential.Stored")({ +export class Info extends Schema.Class("Credential.Info")({ id: ID, integrationID: IntegrationSchema.ID, label: Schema.String, - value: Info, + value: Value, }) {} export interface Interface { /** Returns every stored credential. */ - readonly all: () => Effect.Effect + readonly all: () => Effect.Effect /** Returns stored credentials belonging to one integration. */ - readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect + readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect /** Returns one stored credential by ID. */ - readonly get: (id: ID) => Effect.Effect + readonly get: (id: ID) => Effect.Effect /** Replaces any credential for an integration and returns the new record. */ readonly create: (input: { readonly integrationID: IntegrationSchema.ID - readonly value: Info + readonly value: Value readonly label?: string - }) => Effect.Effect + }) => Effect.Effect /** Updates the label or secret value of a stored credential. */ - readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect /** Removes a stored credential. */ readonly remove: (id: ID) => Effect.Effect } @@ -66,10 +66,10 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service - const decode = Schema.decodeUnknownSync(Info) + const decode = Schema.decodeUnknownSync(Value) const stored = (row: typeof CredentialTable.$inferSelect) => { if (!row.integration_id) return - return new Stored({ + return new Info({ id: row.id, integrationID: row.integration_id, label: row.label, @@ -106,7 +106,7 @@ export const layer = Layer.effect( return row ? stored(row) : undefined }), create: Effect.fn("Credential.create")(function* (input) { - const credential = new Stored({ + const credential = new Info({ id: ID.create(), integrationID: input.integrationID, label: input.label ?? "default", diff --git a/packages/core/src/credential/sql.ts b/packages/core/src/credential/sql.ts index a849092ea05..3afd7284a58 100644 --- a/packages/core/src/credential/sql.ts +++ b/packages/core/src/credential/sql.ts @@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", { id: text().$type().primaryKey(), integration_id: text().$type(), label: text().notNull(), - value: text({ mode: "json" }).$type().notNull(), + value: text({ mode: "json" }).$type().notNull(), connector_id: text(), method_id: text(), active: integer({ mode: "boolean" }), diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 5c044ec60f9..5190e58384a 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -241,32 +241,16 @@ export default { `) yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`) - yield* tx.run( - `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`, - ) - yield* tx.run( - `CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`, - ) + yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`) + yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) - yield* tx.run( - `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`, - ) - yield* tx.run( - `CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`, - ) - yield* tx.run( - `CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`, - ) + yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`) + yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`) + yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`) yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3257fe88401..7f6ae60ae0a 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -123,6 +123,6 @@ const baseLayer = Layer.effect( }), ) -export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer)) export const locationLayer = layer diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 0f123f5b9c1..c019b8034b1 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -232,6 +232,6 @@ export const fffLayer = Layer.effect( }), ) -export const defaultLayer = Layer.unwrap( +export const locationLayer = Layer.unwrap( Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), ) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 4bd27ffd3c9..f9081525b23 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -108,11 +108,11 @@ export type OAuthAuthorization = { } & ( | { readonly mode: "auto" - readonly callback: Effect.Effect + readonly callback: Effect.Effect } | { readonly mode: "code" - readonly callback: (code: string) => Effect.Effect + readonly callback: (code: string) => Effect.Effect } ) @@ -214,8 +214,6 @@ export interface Interface extends State.Transformable { /** Returns all integrations with their methods and current connections. */ readonly list: () => Effect.Effect readonly connection: { - /** Returns active connections for every registered or credential-backed integration. */ - readonly list: () => Effect.Effect> /** Returns the active connection for one integration. */ readonly forIntegration: (id: ID) => Effect.Effect /** Runs a key method and stores the resulting credential. */ @@ -241,7 +239,7 @@ export interface Interface extends State.Transformable { /** Updates a stored credential exposed as a connection. */ readonly update: ( credentialID: Credential.ID, - updates: Partial>, + updates: Partial>, ) => Effect.Effect /** Removes a stored credential connection. */ readonly remove: (credentialID: Credential.ID) => Effect.Effect @@ -353,39 +351,25 @@ export const locationLayer = Layer.effect( finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) - const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { - const connected = saved.map((credential) => ({ + const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => { + const credentials = saved.map((credential) => ({ type: "credential" as const, id: credential.id, label: credential.label, - })) - const detected = entry.methods + })).toReversed() + const env = (entry?.methods ?? []) .filter((method) => method.type === "env") .flatMap((method) => method.names.filter((name) => process.env[name])) .map((name) => ({ type: "env" as const, name })) - return [...connected, ...detected] + return [...credentials, ...env] } - const activeConnection = ( - entry: Entry | undefined, - saved: readonly Credential.Stored[], - ): IntegrationConnection.Info | undefined => { - const credential = saved.at(-1) - if (credential) return { type: "credential", id: credential.id, label: credential.label } - if (!entry) return - const name = entry.methods - .filter((method) => method.type === "env") - .flatMap((method) => method.names) - .find((name) => process.env[name]) - if (name) return { type: "env", name } - } - - const project = (entry: Entry, saved: readonly Credential.Stored[]) => + const project = (entry: Entry, connections: IntegrationConnection.Info[]) => new Info({ id: entry.ref.id, name: entry.ref.name, methods: entry.methods, - connections: connections(entry, saved), + connections, }) const authorize = (effect: Effect.Effect) => @@ -399,7 +383,7 @@ export const locationLayer = Layer.effect( return error instanceof Error ? error.message : String(error) } - const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { + const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { const now = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(attempts, (current) => { const attempt = current.get(attemptID) @@ -450,28 +434,18 @@ export const locationLayer = Layer.effect( get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined - return project(entry, yield* credentials.list(id)) + return project(entry, resolveConnections(entry, yield* credentials.list(id))) }), list: Effect.fn("Integration.list")(function* () { - return (yield* Effect.forEach(state.get().integrations.values(), (entry) => - Effect.gen(function* () { - return project(entry, yield* credentials.list(entry.ref.id)) - }), - )).toSorted((a, b) => a.name.localeCompare(b.name)) + const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) + return Array.from(state.get().integrations.values(), (entry) => + project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])), + ).toSorted((a, b) => a.name.localeCompare(b.name)) }), connection: { - list: Effect.fn("Integration.connection.list")(function* () { - const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) - return new Map( - new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => { - const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? []) - return connection ? [[id, connection] as const] : [] - }), - ) - }), forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { const entry = state.get().integrations.get(id) - return activeConnection(entry, yield* credentials.list(id)) + return resolveConnections(entry, yield* credentials.list(id))[0] }), key: Effect.fn("Integration.connection.key")(function* (input) { const method = state diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index d4e617ebf42..787c62c1909 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -44,7 +44,7 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => { +const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => { if (credential?.value.type === "key") return Auth.value(credential.value.key) if (credential?.value.type === "oauth") return Auth.value(credential.value.access) const value = model.request.body.apiKey ?? model.api.settings?.apiKey @@ -85,7 +85,7 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, connection?: IntegrationConnection.Info, - credential?: Credential.Stored, + credential?: Credential.Info, ): Effect.Effect => { const resolved = credential?.value.metadata === undefined diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index cc1051bc2c9..bb4b256f893 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -11,7 +11,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -import { required } from "./plugin/provider-helper" + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} const locationLayer = Layer.succeed( Location.Service, @@ -21,12 +25,7 @@ const it = testEffect( Catalog.locationLayer.pipe( Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), + Layer.provideMerge(Credential.defaultLayer), ), ) @@ -48,38 +47,30 @@ describe("CatalogV2", () => { it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") - const first = { - id: Credential.ID.create(), - integrationID, - label: "First", - value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), - } - const second = { - id: Credential.ID.create(), - integrationID, - label: "Second", - value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), - } - let active = first const layer = Catalog.locationLayer.pipe( Layer.fresh, Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.sync(() => [active]), - list: () => Effect.sync(() => [active]), - }), - ), + Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)), ) return Effect.gen(function* () { const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* credentials.create({ + integrationID, + label: "First", + value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) - active = second + yield* credentials.create({ + integrationID, + label: "Second", + value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) }).pipe(Effect.provide(layer)) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 054c6871d58..19311363edc 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,14 +1,52 @@ import { describe, expect } from "bun:test" -import { Effect, Option, Schema } from "effect" +import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, required, withEnv } from "../plugin/provider-helper" -import { catalogHost, host, integrationHost } from "../plugin/host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (config: Config.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)), + }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} function request(headers: Record, variant?: string) { return { @@ -23,8 +61,6 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("partitions existing model variant bodies without changing config shape", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -57,12 +93,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants).toMatchObject([ @@ -82,8 +113,6 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("uses the effective provider package across layered config", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -116,12 +145,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants[0]).toMatchObject({ @@ -137,7 +161,6 @@ describe("ConfigProviderPlugin.Plugin", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("custom") const modelID = ModelV2.ID.make("chat") const config = Config.Service.of({ @@ -217,12 +240,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const provider = required(yield* catalog.provider.get(providerID)) const model = required(yield* catalog.model.get(providerID, modelID)) diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index c038598543a..6c7f08e112a 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -1,26 +1,14 @@ -import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Credential } from "@opencode-ai/core/credential" -import { Database } from "@opencode-ai/core/database/database" import { Integration } from "@opencode-ai/core/integration" -import { tmpdir } from "./fixture/tmpdir" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" -function layer(directory: string) { - return Credential.layer.pipe( - Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)), - ) -} +const it = testEffect(Credential.defaultLayer) describe("Credential", () => { - it.live("stores, updates, lists, and removes credentials", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { + it.effect("stores, updates, lists, and removes credentials", () => + Effect.gen(function* () { const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const created = yield* credentials.create({ @@ -42,8 +30,6 @@ describe("Credential", () => { yield* credentials.remove(replacement.id) expect(yield* credentials.list(integrationID)).toEqual([]) - }).pipe(Effect.provide(layer(tmp.path))), - ), - ), + }), ) }) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index fb5e195e2a8..2001de4efec 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -420,13 +420,12 @@ describe("EventV2", () => { const readStarted = yield* Deferred.make() const continueRead = yield* Deferred.make() let pause = true - const database = Database.layerFromPath(":memory:") const eventLayer = EventV2.layerWith({ beforeAggregateRead: () => pause ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) : Effect.void, - }).pipe(Layer.provide(database)) + }).pipe(Layer.provide(Database.defaultLayer)) yield* Effect.gen(function* () { const events = yield* EventV2.Service @@ -441,7 +440,7 @@ describe("EventV2", () => { expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ [0, { id: aggregateID, text: "during handoff" }], ]) - }).pipe(Effect.provide(Layer.mergeAll(database, eventLayer))) + }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) }), ) diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index 10f61d8a97f..31371b0ac7e 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -5,7 +5,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import path from "path" -const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) +const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer) const { effect: it } = testEffect(live) describe("FSUtil", () => { diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index cdc8344de50..77d0a9e33cb 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -22,7 +22,7 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 }) - expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))]) + expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")]) }), ), ) @@ -35,7 +35,7 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n")) const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 }) expect(result).toHaveLength(1) - expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts"))) + expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts")) expect(result[0]?.submatches[0]?.text).toBe("needle") }), ), diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 00b3ffbd13f..40d8ed9dc36 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -1,6 +1,8 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect, Layer } from "effect" +import { tmpdir } from "./tmpdir" export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { return { @@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute vcs: input.vcs, } satisfies Location.Interface } + +export const tempLocationLayer = Layer.unwrap( + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.map((tmp) => { + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + return Layer.succeed(Location.Service, Location.Service.of(location(ref))) + }), + ), +) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index ac9cd33e8d1..c95dbdf381b 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -4,45 +4,15 @@ import * as TestClock from "effect/testing/TestClock" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" -const layer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - list: () => Effect.succeed([]), - }), +const it = testEffect( + Integration.locationLayer.pipe( + Layer.provideMerge(Credential.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), ), ) -function connectionLayer( - created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }>, -) { - return Integration.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: (input) => - Effect.sync(() => { - created.push(input) - return new Credential.Stored({ - id: Credential.ID.create(), - integrationID: input.integrationID, - label: input.label ?? "default", - value: input.value, - }) - }), - list: () => Effect.succeed([]), - }), - ), - ) -} - describe("Integration", () => { it.effect("registers integrations through the editor", () => Effect.gen(function* () { @@ -59,7 +29,7 @@ describe("Integration", () => { yield* Scope.close(scope, Exit.void) expect(yield* integrations.get(openai)).toBeUndefined() - }).pipe(Effect.provide(layer)), + }), ) it.effect("reveals the previous registration when an override closes", () => @@ -80,7 +50,7 @@ describe("Integration", () => { yield* Scope.close(second, Exit.void) expect((yield* integrations.get(id))?.name).toBe("OpenAI") expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id]) - }).pipe(Effect.provide(layer)), + }), ) it.effect("registers and overrides methods independently", () => @@ -128,17 +98,13 @@ describe("Integration", () => { yield* Scope.close(second, Exit.void) expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" }) expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })]) - }).pipe(Effect.provide(layer)), + }), ) - it.effect("connects with a key and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("connects with a key and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") yield* integrations.transform((editor) => @@ -158,25 +124,21 @@ describe("Integration", () => { label: "Work", }) - expect(created).toEqual([ - { + expect(yield* credentials.list(integrationID)).toEqual([ + expect.objectContaining({ integrationID, label: "Work", value: new Credential.Key({ type: "key", key: "secret" }), - }, + }), ]) expect((yield* Fiber.join(updated)).length).toBe(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + }), + ) - it.effect("completes code OAuth once and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes code OAuth once and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") yield* integrations.transform((editor) => @@ -212,29 +174,27 @@ describe("Integration", () => { expect(attempt.mode).toBe("code") yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) - expect(created[0]).toEqual({ - integrationID, - label: "Personal", - value: new Credential.OAuth({ - type: "oauth", - methodID, - access: "access", - refresh: "refresh", - expires: 1, - metadata: { code: "1234" }, + expect((yield* credentials.list(integrationID))[0]).toEqual( + expect.objectContaining({ + integrationID, + label: "Personal", + value: new Credential.OAuth({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + metadata: { code: "1234" }, + }), }), - }) - }).pipe(Effect.provide(connectionLayer(created))) - }) + ) + }), + ) - it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") let closed = false @@ -261,18 +221,14 @@ describe("Integration", () => { expect(closed).toBe(false) yield* integrations.attempt.cancel(attempt.attemptID) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) - it.effect("completes auto OAuth in the background", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes auto OAuth in the background", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") yield* integrations.transform((editor) => @@ -297,18 +253,14 @@ describe("Integration", () => { status: "complete", time: attempt.time, }) - expect(created).toHaveLength(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toHaveLength(1) + }), + ) - it.effect("expires abandoned OAuth attempts", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("expires abandoned OAuth attempts", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") let closed = false @@ -337,34 +289,12 @@ describe("Integration", () => { time: attempt.time, }) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) it.effect("projects credential and env connections", () => { const integrationID = Integration.ID.make("acme") - const rows = [ - { - id: Credential.ID.create(), - integrationID, - label: "Work", - value: new Credential.Key({ type: "key", key: "a" }), - }, - { - id: Credential.ID.create(), - integrationID, - label: "Personal", - value: new Credential.Key({ type: "key", key: "b" }), - }, - ] - const projectionLayer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))), - }), - ), - ) return Effect.acquireUseRelease( Effect.sync(() => { const previous = process.env.INTEGRATION_TEST_ACME_KEY @@ -375,6 +305,7 @@ describe("Integration", () => { () => Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service yield* integrations.transform((editor) => editor.method.update({ integrationID, @@ -384,23 +315,33 @@ describe("Integration", () => { }, }), ) + const work = yield* credentials.create({ + integrationID, + label: "Work", + value: new Credential.Key({ type: "key", key: "a" }), + }) + const personal = yield* credentials.create({ + integrationID, + label: "Personal", + value: new Credential.Key({ type: "key", key: "b" }), + }) // Stored credentials and detected env vars appear as connections. expect((yield* integrations.get(integrationID))?.connections).toEqual([ - { type: "credential", id: rows[0]!.id, label: "Work" }, { type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }, { type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, ]) expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }) - }).pipe(Effect.provide(projectionLayer)), + expect(work.id).not.toBe(personal.id) + }), (previous) => Effect.sync(() => { if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 9e75bbb641c..21acc40ee71 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -36,8 +36,7 @@ const it = testEffect( Layer.mergeAll( Project.defaultLayer, EventV2.defaultLayer, - Credential.defaultLayer, - Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))), + Credential.defaultLayer.pipe(Layer.fresh), Npm.defaultLayer, ModelsDev.defaultLayer, FSUtil.defaultLayer, diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 5f7fbb16d3a..84efa4cbadc 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -21,34 +21,39 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events)) const project = Project.layer.pipe( - Layer.provide(database), + Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), - Layer.provide(directories), + Layer.provide(ProjectDirectories.defaultLayer), ) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(database), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(project), - Layer.provide(store), + Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionExecution.noopLayer), ) const layer = MoveSession.layer.pipe( - Layer.provide(database), + Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), - Layer.provide(events), + Layer.provide(EventV2.defaultLayer), Layer.provide(project), Layer.provide(sessions), ) const it = testEffect( - Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions), + Layer.mergeAll( + layer, + Database.defaultLayer, + EventV2.defaultLayer, + ProjectDirectories.defaultLayer, + project, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), ) function abs(input: string) { diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 2120a9f51ad..0f07ed54764 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -18,29 +18,25 @@ import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") const current = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) -const events = EventV2.layer.pipe(Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(SessionExecution.noopLayer), ) -const saved = PermissionSaved.layer.pipe(Layer.provide(database)) const layer = PermissionV2.locationLayer.pipe( - Layer.provideMerge(database), - Layer.provideMerge(store), - Layer.provideMerge(events), + Layer.provideMerge(Database.defaultLayer), + Layer.provideMerge(SessionStore.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(current), Layer.provideMerge(sessions), Layer.provideMerge(SessionExecution.noopLayer), - Layer.provideMerge(saved), + Layer.provideMerge(PermissionSaved.defaultLayer), ) const it = testEffect(layer) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts new file mode 100644 index 00000000000..3faa65a6587 --- /dev/null +++ b/packages/core/test/plugin/fixture.ts @@ -0,0 +1,48 @@ +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { CommandV2 } from "@opencode-ai/core/command" +import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { Reference } from "@opencode-ai/core/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { Effect, Layer } from "effect" +import { tempLocationLayer } from "../fixture/location" + +export const PluginTestLayer = Layer.mergeAll( + AgentV2.locationLayer, + CommandV2.locationLayer, + Catalog.locationLayer, + FileSystem.locationLayer, + PluginV2.locationLayer, + Reference.locationLayer, + SkillV2.locationLayer, +).pipe( + Layer.provideMerge( + Layer.mergeAll( + Credential.defaultLayer, + EventV2.defaultLayer, + FSUtil.defaultLayer, + Global.defaultLayer, + Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }), + ), + RepositoryCache.defaultLayer, + SkillDiscovery.defaultLayer, + Ripgrep.defaultLayer, + tempLocationLayer, + ), + ), +) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 6b3e153c3ce..c872b6fe65e 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -24,11 +24,7 @@ const locationLayer = Layer.succeed( ) const plugins = PluginV2.layer.pipe(Layer.provide(events)) const policy = Policy.layer.pipe(Layer.provide(locationLayer)) -const connections = Credential.layer.pipe( - Layer.fresh, - Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), - Layer.provide(events), -) +const connections = Credential.defaultLayer.pipe(Layer.fresh) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) const catalog = Catalog.layer.pipe( Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index 017f60fff30..5fb8b16bf00 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -3,17 +3,35 @@ import { createAlibaba } from "@ai-sdk/alibaba" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) }) +}) describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "alibaba" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -23,10 +41,17 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "alibaba" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -36,11 +61,14 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-alibaba", "qwen"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/alibaba", options: { name: "custom-alibaba", apiKey: "test" }, }, @@ -56,8 +84,11 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) - const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } }) + yield* addPlugin() + const item = new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" }, + }) const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) const language = result.sdk?.languageModel(item.api.id) expect(language?.modelId).toBe("qwen-plus") diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index aadefcb5c03..7b3cea5c1a3 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -1,10 +1,61 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) @@ -28,11 +79,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) { describe("AmazonBedrockPlugin", () => { it.effect("moves endpoint option to api URL", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) yield* catalog.transform((catalog) => { - const bedrock = provider("amazon-bedrock", { + const bedrock = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, request: { headers: {}, @@ -44,6 +94,7 @@ describe("AmazonBedrockPlugin", () => { item.request = bedrock.request }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) expect(result.api).toEqual({ type: "aisdk", @@ -58,11 +109,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -83,11 +137,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -117,11 +174,21 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -137,11 +204,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", region: "eu-west-1" }, }, @@ -156,11 +226,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -175,11 +248,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -195,11 +271,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -224,11 +303,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -252,12 +334,17 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), package: "@ai-sdk/amazon-bedrock/mantle", options: { @@ -281,12 +368,17 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), sdk: fakeSelectorSdk(calls), options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, @@ -296,8 +388,16 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + ), + api: { + id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), sdk: fakeSelectorSdk(calls), options: { region: "us-east-1" }, @@ -311,12 +411,17 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/anthropic", + }, }), package: "@ai-sdk/amazon-bedrock/anthropic", options: { name: "amazon-bedrock" }, @@ -340,11 +445,21 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -371,11 +486,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -384,7 +502,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, @@ -393,7 +514,17 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, @@ -402,7 +533,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "ap-northeast-1" }, }, @@ -411,7 +545,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "ap-southeast-2" }, }, @@ -432,11 +569,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -517,12 +657,15 @@ describe("AmazonBedrockPlugin", () => { expected: "au.anthropic.claude-sonnet-4-5", }, ] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() for (const item of cases) { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", item.modelID), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: item.region }, }, @@ -537,11 +680,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index d31574d0f69..389fa8c6f85 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -1,19 +1,34 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("AnthropicPlugin", () => { it.effect("applies legacy beta headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AnthropicPlugin) yield* catalog.transform((catalog) => { - const item = provider("anthropic", { + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.anthropic), api: { type: "aisdk", package: "@ai-sdk/anthropic" }, request: { headers: { Existing: "1" }, body: {} }, }) @@ -22,6 +37,7 @@ describe("AnthropicPlugin", () => { draft.request = item.request }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", ) @@ -31,10 +47,9 @@ describe("AnthropicPlugin", () => { it.effect("ignores non-Anthropic providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AnthropicPlugin) - yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin() expect( required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], ).toBeUndefined() @@ -44,54 +59,43 @@ describe("AnthropicPlugin", () => { it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), package: "@ai-sdk/anthropic", options: { name: "custom-anthropic", apiKey: "test" }, }, {}, ) - expect(providers).toEqual(["custom-anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic") }), ) it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), package: "@ai-sdk/anthropic", options: { name: "anthropic", apiKey: "test" }, }, {}, ) - expect(providers).toEqual(["anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic") }), ) }) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 6d9139c7336..222e25e9b99 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -1,23 +1,73 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzureCognitiveServicesPlugin", () => { it.effect("maps the resource env var to the Azure SDK baseURL", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) expect(result.api).toEqual({ type: "aisdk", @@ -33,14 +83,16 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("leaves baseURL unset without resource env and ignores other providers", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* catalog.transform((catalog) => { - const azure = provider("azure-cognitive-services", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")), api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) - const openai = provider("openai") + const openai = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) catalog.provider.update(azure.id, (item) => { item.api = azure.api }) @@ -48,6 +100,7 @@ describe("AzureCognitiveServicesPlugin", () => { item.api = openai.api }) }) + yield* addPlugin() const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) expect(azure.request.body.baseURL).toBeUndefined() @@ -62,11 +115,17 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("deployment"), + ), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true }, }, @@ -80,15 +139,32 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("deployment"), + ), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) const ignored = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual(["responses:deployment"]) @@ -101,11 +177,17 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "messages-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("messages-deployment"), + ), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, options: {}, }, @@ -114,7 +196,13 @@ describe("AzureCognitiveServicesPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "chat-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("chat-deployment"), + ), + api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, options: {}, }, @@ -123,7 +211,13 @@ describe("AzureCognitiveServicesPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "language-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("language-deployment"), + ), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: sdk.languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index baa6d4f7394..10c2a005dcc 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,23 +1,73 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzurePlugin", () => { it.effect("resolves resourceName from env", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.api = { type: "aisdk", package: "@ai-sdk/azure" } }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -26,10 +76,10 @@ describe("AzurePlugin", () => { it.effect("keeps explicit resourceName over env and ignores other providers", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "from-config" } }, }) @@ -39,7 +89,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() }), @@ -49,10 +99,10 @@ describe("AzurePlugin", () => { it.effect("falls back to env when configured resourceName is blank", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "" } }, }) @@ -61,7 +111,7 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -70,10 +120,10 @@ describe("AzurePlugin", () => { it.effect("falls back to env when configured resourceName is whitespace", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: " " } }, }) @@ -82,7 +132,7 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -92,11 +142,14 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("azure", "deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/azure", options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, }, @@ -111,11 +164,18 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() const exit = yield* plugin .trigger( "aisdk.sdk", - { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure" }, + }, {}, ) .pipe(Effect.exit) @@ -128,10 +188,17 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }, {}, ) expect(calls).toEqual(["chat:deployment"]) @@ -142,10 +209,17 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }, {}, ) expect(calls).toEqual(["chat:deployment"]) @@ -156,11 +230,13 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure", "deployment", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { useCompletionUrls: true } }, }), sdk: fakeSelectorSdk(calls), @@ -176,15 +252,29 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) const ignored = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual(["responses:deployment"]) @@ -200,11 +290,14 @@ describe("AzurePlugin", () => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure", "messages-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, options: {}, }, @@ -212,7 +305,14 @@ describe("AzurePlugin", () => { ) yield* plugin.trigger( "aisdk.language", - { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: make("languageModel") }, + options: {}, + }, {}, ) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index 5bcb9f7a0b6..5501ad39e3f 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -1,12 +1,22 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cerebrasOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CerebrasPlugin.id, effect: CerebrasPlugin.effect(host) }) +}) void mock.module("@ai-sdk/cerebras", () => ({ createCerebras: (options: Record) => { @@ -21,16 +31,15 @@ void mock.module("@ai-sdk/cerebras", () => ({ describe("CerebrasPlugin", () => { it.effect("applies the legacy integration header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, CerebrasPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.request.headers.Existing = "1" }) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode", }) @@ -39,11 +48,10 @@ describe("CerebrasPlugin", () => { it.effect("ignores non-Cerebras providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, CerebrasPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({}) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({}) }), ) @@ -51,11 +59,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/cerebras", options: { name: "custom-cerebras", apiKey: "test" }, }, @@ -70,11 +88,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/cerebras", options: { name: "configured-cerebras", apiKey: "test" }, }, @@ -88,11 +116,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/groq", options: { name: "custom-cerebras", apiKey: "test" }, }, diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 2332a3ca27d..34e6261d318 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,8 +1,41 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" -import { addPlugin, it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) }) +}) + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} const aiGatewayCalls: Record[] = [] const unifiedCalls: string[] = [] @@ -78,11 +111,17 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -98,12 +137,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -142,12 +187,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -171,12 +222,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -208,12 +265,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -239,12 +302,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -261,12 +330,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -284,12 +359,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -313,12 +394,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, }, @@ -336,12 +423,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -364,12 +461,18 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("openai/gpt-5"), + ), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-ai-gateway" }, }, diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 8e27781d07a..f6da837d8ba 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -3,9 +3,59 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -37,12 +87,15 @@ describe("CloudflareWorkersAIPlugin", () => { provider.api = { type: "aisdk", package: "test-provider" } }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, }, @@ -61,14 +114,13 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", @@ -82,12 +134,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, @@ -102,7 +160,6 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("uses env account ID over configured account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { @@ -110,7 +167,7 @@ describe("CloudflareWorkersAIPlugin", () => { provider.request.body.accountId = "configured-acct" }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", @@ -124,12 +181,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/openai-compatible", options: { @@ -153,12 +216,14 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), api: { + id: ModelV2.ID.make("@cf/model"), type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", @@ -183,11 +248,14 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -202,12 +270,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/anthropic", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/anthropic", options: { name: "cloudflare-workers-ai" }, diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index c653f65a014..f0d09b8411e 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -2,10 +2,34 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cohereOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CoherePlugin.id, effect: CoherePlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/cohere", () => ({ createCohere: (options: Record) => { @@ -24,18 +48,32 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cohere" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "cohere" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -45,11 +83,14 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cohere", "command-r-plus"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/cohere", options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, }, @@ -70,10 +111,17 @@ describe("CoherePlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }, {}, ) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 7e2b6322f19..db7dd104297 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -1,20 +1,25 @@ import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itAISDK = testEffect( - Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), -) -const deepinfraOptions: Record[] = [] +const it = testEffect(PluginTestLayer) +const deepinfraOptions: Record[] = [] const deepinfraLanguageModels: string[] = [] +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) }) +}) + void mock.module("@ai-sdk/deepinfra", () => ({ - createDeepInfra: (options: Record) => { + createDeepInfra: (options: Record) => { const captured = { ...options } deepinfraOptions.push(captured) return { @@ -36,10 +41,17 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -50,11 +62,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-deepinfra", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), package: "@ai-sdk/deepinfra", options: { name: "custom-deepinfra", apiKey: "test" }, }, @@ -69,11 +84,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("deepinfra", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), package: "@ai-sdk/deepinfra", options: { name: "deepinfra", apiKey: "test" }, }, @@ -88,7 +106,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const packages = [ "unmatched-package", "@ai-sdk/deepinfra-compatible", @@ -98,7 +116,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: item, + options: { name: "deepinfra" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() @@ -106,7 +131,14 @@ describe("DeepInfraPlugin", () => { ) const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -114,17 +146,36 @@ describe("DeepInfraPlugin", () => { }), ) - itAISDK.effect("uses the default languageModel selection for DeepInfra models", () => + it.effect("uses the default languageModel selection for DeepInfra models", () => Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, DeepInfraPlugin) - const language = yield* aisdk.language( - model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { - api: { type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), + yield* addPlugin() + const sdkEvent = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("deepinfra"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + type: "aisdk", + package: "@ai-sdk/deepinfra", + }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, + {}, ) + const result = yield* plugin.trigger( + "aisdk.language", + { model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }, + {}, + ) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.provider).toBe("deepinfra.chat") expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) }), diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index f3b0bf898f7..150c9ea84d6 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -1,43 +1,40 @@ import { Npm } from "@opencode-ai/core/npm" import { describe, expect } from "bun:test" -import { Cause, Effect, Layer, Option } from "effect" +import { Cause, Effect, Layer } from "effect" import fs from "fs/promises" import os from "os" import path from "path" import { fileURLToPath } from "url" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { host } from "./host" -import { fixtureProvider, it, model, npmLayer } from "./provider-helper" +import { PluginTestLayer } from "./fixture" +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProviderPath = fileURLToPath(fixtureProvider) -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) +const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer))) -function npmEntrypointLayer(entrypoint?: string) { - return Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }), - ) +function npmEntrypoint(entrypoint?: string) { + return Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) } -function dynamicPlugin(layer = npmLayer) { - return { +const addPlugin = Effect.fn(function* (npm?: Npm.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: DynamicProviderPlugin.id, - effect: Effect.gen(function* () { - yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service })) - }).pipe(Effect.provide(layer)), - } -} + effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host), + }) +}) function tempEntrypoint(source: string) { return Effect.acquireRelease( @@ -55,11 +52,14 @@ describe("DynamicProviderPlugin", () => { it.effect("creates an SDK from a provider factory export", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom", marker: "dynamic" }, }, @@ -74,11 +74,14 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const sdk = { marker: "existing" } - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom", marker: "dynamic" }, }, @@ -91,11 +94,14 @@ describe("DynamicProviderPlugin", () => { it.effect("injects the provider ID as the SDK factory name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-provider", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom-provider", marker: "dynamic" }, }, @@ -108,11 +114,14 @@ describe("DynamicProviderPlugin", () => { it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath))) + yield* addPlugin(npmEntrypoint(fixtureProviderPath)) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("npm-provider", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, + }), package: "fixture-provider", options: { name: "npm-provider", marker: "npm" }, }, @@ -126,9 +135,14 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer())) + yield* addPlugin(npmEntrypoint()) const exit = yield* aisdk - .language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -139,10 +153,13 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const exit = yield* aisdk .language( - model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }), + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" }, + }), ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") @@ -155,9 +172,14 @@ describe("DynamicProviderPlugin", () => { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint))) + yield* addPlugin(npmEntrypoint(tmp.entrypoint)) const exit = yield* aisdk - .language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -168,9 +190,10 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const language = yield* aisdk.language( - model("custom", "alias", { + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider }, }), ) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 6627d185a58..3bd6d24963c 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -1,11 +1,22 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gatewayCalls: Record[] = [] const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) }) +}) mock.module("@ai-sdk/gateway", () => ({ createGateway(options: Record) { @@ -27,10 +38,17 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "gateway" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -42,12 +60,22 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("vercel", "anthropic/claude-sonnet-4"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("vercel"), + ModelV2.ID.make("anthropic/claude-sonnet-4"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/gateway", options: { name: "vercel", apiKey: "test-key" }, }, @@ -63,19 +91,33 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() for (const modelID of vercelGatewayModels) { const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/vercel", + options: { name: "vercel" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel" }, + }, {}, ) expect(result.sdk).toBeDefined() diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index d23672f6fe9..f7ca619eae0 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -3,19 +3,51 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("github-copilot", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "github-copilot" }, }, @@ -24,7 +56,10 @@ describe("GithubCopilotPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("github-copilot", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/github-copilot", options: { name: "github-copilot" }, }, @@ -39,11 +74,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "claude-sonnet-4"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -57,11 +95,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -75,30 +116,68 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("github-copilot"), + ModelV2.ID.make("gpt-5-mini-2025-08-07"), + ), + api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([ @@ -115,11 +194,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -128,7 +210,10 @@ describe("GithubCopilotPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -137,7 +222,10 @@ describe("GithubCopilotPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -149,13 +237,12 @@ describe("GithubCopilotPlugin", () => { it.effect("disables gpt-5-chat-latest before Copilot language selection", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GithubCopilotPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, @@ -165,13 +252,12 @@ describe("GithubCopilotPlugin", () => { it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GithubCopilotPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, @@ -183,10 +269,17 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index b4277d140fb..1940bba937d 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,12 +1,43 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gitlabSDKOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GitLabPlugin.id, effect: GitLabPlugin.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", @@ -32,10 +63,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }, {}, ) expect(gitlabSDKOptions).toHaveLength(1) @@ -65,10 +103,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }, {}, ) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") @@ -86,11 +131,14 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("gitlab", "claude"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), package: "gitlab-ai-provider", options: { name: "gitlab", @@ -127,10 +175,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "gitlab" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -142,11 +197,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-custom", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { workflowRef: "ref", workflowDefinition: "definition" }, @@ -178,11 +235,14 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-exact"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, + }), sdk: { workflowChat: (id: string, options: unknown) => { calls.push([id, options]) @@ -205,11 +265,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-custom", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { featureFlags: { request_flag: true } }, @@ -234,11 +296,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "claude", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, request: { headers: { h: "v" }, body: {} }, }), sdk: { diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 57c90e8148f..fe9b0b0d958 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -1,10 +1,50 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: definition.id, effect: definition.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function selector(calls: string[]) { + return (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + } +} describe("GoogleVertexAnthropicPlugin", () => { it.effect("resolves legacy project and location env on provider update", () => @@ -19,17 +59,19 @@ describe("GoogleVertexAnthropicPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), ) - const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) - expect(provider.request.body.project).toBe("cloud-project") - expect(provider.request.body.location).toBe("cloud-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project, + ).toBe("cloud-project") + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("cloud-location") }), ), ) @@ -37,9 +79,7 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured project and location over env fallback", () => withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } @@ -47,9 +87,13 @@ describe("GoogleVertexAnthropicPlugin", () => { provider.request.body.location = "configured-location" }), ) - const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) - expect(provider.request.body.project).toBe("configured-project") - expect(provider.request.body.location).toBe("configured-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe( + "configured-project", + ) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("configured-location") }), ), ) @@ -67,11 +111,17 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex-anthropic" }, }, @@ -90,11 +140,17 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex-anthropic" }, }, @@ -110,11 +166,14 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "eu" }, }, @@ -129,11 +188,14 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, }, @@ -146,12 +208,15 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const sdkResult = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", " claude-sonnet-4-5 "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "us" }, }, @@ -160,7 +225,10 @@ describe("GoogleVertexAnthropicPlugin", () => { const languageResult = yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", " claude-sonnet-4-5 "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), sdk: sdkResult.sdk, options: {}, }, @@ -178,12 +246,18 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex-anthropic", " claude-sonnet-4-5 "), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make(" claude-sonnet-4-5 "), + ), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, options: {}, }, {}, @@ -196,12 +270,15 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", "claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, options: {}, }, {}, diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index bebfa1dc85b..f5f62f8df79 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,13 +1,63 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const vertexOptions: Record[] = [] const googleAuthOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GoogleVertexPlugin.id, effect: GoogleVertexPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/google-vertex", () => ({ createVertex: (options: Record) => { @@ -37,9 +87,7 @@ void mock.module("google-auth-library", () => ({ describe("GoogleVertexPlugin", () => { it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.opencode, (provider) => { provider.api = { @@ -49,6 +97,7 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) expect(provider.request.body).toEqual({}) @@ -67,9 +116,7 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -79,6 +126,7 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("google-cloud-project") expect(provider.request.body.location).toBe("google-vertex-location") @@ -107,7 +155,6 @@ describe("GoogleVertexPlugin", () => { vertexOptions.length = 0 const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -117,12 +164,18 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, }), package: "@ai-sdk/google-vertex", options: { name: "google-vertex" }, @@ -154,9 +207,7 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -168,6 +219,7 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "global" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("global") @@ -182,9 +234,7 @@ describe("GoogleVertexPlugin", () => { it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -196,6 +246,7 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "eu" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.api).toEqual({ type: "aisdk", @@ -217,15 +268,14 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.request.body.project = "config-project" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("us-central1") @@ -243,12 +293,17 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, }), package: "@ai-sdk/google-vertex", options: { name: "google-vertex" }, @@ -268,21 +323,17 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("capture-openai-compatible"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.promise(async () => { - if (evt.model.providerID !== "google-vertex") return - if (evt.package !== "@ai-sdk/openai-compatible") return - expect(typeof evt.options.fetch).toBe("function") - await evt.options.fetch("https://vertex.example", { - headers: { "x-test": "1" }, - }) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (evt) => + Effect.promise(async () => { + if (evt.model.providerID !== "google-vertex") return + if (evt.package !== "@ai-sdk/openai-compatible") return + expect(typeof evt.options.fetch).toBe("function") + await evt.options.fetch("https://vertex.example", { + headers: { "x-test": "1" }, + }) }), - }) + ) const originalFetch = fetch ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( input: Parameters[0], @@ -297,8 +348,13 @@ describe("GoogleVertexPlugin", () => { plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + }, }), package: "@ai-sdk/openai-compatible", options: { name: "google-vertex" }, @@ -322,11 +378,14 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", " gemini-2.5-pro "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index c1fab4201e8..1197957f5a9 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -1,26 +1,33 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) }) +}) describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GooglePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-google", "gemini"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), package: "@ai-sdk/google", options: { name: "custom-google", apiKey: "test" }, }, @@ -34,34 +41,49 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GooglePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google" }, + }, {}, ) expect(result.sdk).toBeUndefined() }), ) - itWithAISDK.effect("uses default languageModel loading with provider ID parity", () => + it.effect("uses default languageModel loading with provider ID parity", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, GooglePlugin) - const language = yield* aisdk.language( - model("custom-google", "alias", { - api: { - id: ModelV2.ID.make("gemini-api"), - type: "aisdk", - package: "@ai-sdk/google", - }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, - }), + yield* addPlugin() + const sdkEvent = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }, + {}, ) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: sdkEvent.model, + sdk: sdkEvent.sdk, + options: sdkEvent.options, + }, + {}, + ) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("gemini-api") expect(language.provider).toBe("custom-google") }), diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index 71eb1eeabdf..dbc97205b26 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -1,26 +1,37 @@ import { describe, expect } from "bun:test" import { createGroq } from "@ai-sdk/groq" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const aisdkIt = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) }) +}) describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -30,10 +41,17 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -43,10 +61,17 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq/compat", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -56,11 +81,14 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-groq", "llama"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), package: "@ai-sdk/groq", options: { name: "custom-groq", apiKey: "test" }, }, @@ -75,26 +103,32 @@ describe("GroqPlugin", () => { }), ) - aisdkIt.effect("uses the default languageModel(api.id) behavior", () => + it.effect("uses the default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, GroqPlugin) - const result = yield* aisdk.language( - model("groq", "alias", { - api: { - id: ModelV2.ID.make("llama-api"), - type: "aisdk", - package: "@ai-sdk/groq", - }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, - }), + yield* addPlugin() + const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { + name: string + }) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), + api: { + id: ModelV2.ID.make("llama-api"), + type: "aisdk", + package: "@ai-sdk/groq", + }, + }), + sdk, + options: { name: "groq", apiKey: "test" }, + }, + {}, ) - expect(result.modelId).toBe("llama-api") - expect(result.provider).toBe("groq.chat") + const language = result.language ?? sdk.languageModel(result.model.api.id) + expect(language.modelId).toBe("llama-api") + expect(language.provider).toBe("groq.chat") }), ) }) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts deleted file mode 100644 index d30286bc093..00000000000 --- a/packages/core/test/plugin/provider-helper.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Npm } from "@opencode-ai/core/npm" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { expect } from "bun:test" -import { Effect, Layer, Option } from "effect" -import { Catalog } from "@opencode-ai/core/catalog" -import { Integration } from "@opencode-ai/core/integration" -import { Credential } from "@opencode-ai/core/credential" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { aisdkHost, catalogHost, host, integrationHost } from "./host" - -export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href - -export function required(value: T | undefined): T { - if (value === undefined) throw new Error("Expected value") - return value -} - -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) - -export const npmLayer = Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: undefined }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }), -) - -export const catalogLayer = Layer.succeed( - Catalog.Service, - Catalog.Service.of({ - transform: (_transform) => Effect.die("unexpected catalog.transform"), - rebuild: () => Effect.die("unexpected catalog.rebuild"), - provider: { - get: () => Effect.die("unexpected provider.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - }, - model: { - get: () => Effect.die("unexpected model.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - default: () => Effect.succeed(undefined), - small: () => Effect.succeed(undefined), - }, - }), -) - -const integrations = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), -) - -export const it = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(integrations), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - }), - ), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge(npmLayer), - Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), - ), -) - -export function addPlugin(plugin: PluginV2.Interface, definition: Plugin) { - return Effect.gen(function* () { - const catalog = yield* Effect.serviceOption(Catalog.Service) - const integration = yield* Effect.serviceOption(Integration.Service) - const npm = yield* Effect.serviceOption(Npm.Service) - const effect = - typeof definition.effect === "function" - ? definition.effect( - host({ - aisdk: aisdkHost(plugin), - ...(Option.isSome(catalog) ? { catalog: catalogHost(catalog.value) } : {}), - ...(Option.isSome(integration) ? { integration: integrationHost(integration.value) } : {}), - ...(Option.isSome(npm) ? { npm: npm.value } : {}), - }), - ) - : definition.effect - yield* plugin.add({ id: definition.id, effect }) - }) -} - -type ProviderInput = Partial> & { - api?: ProviderV2.Api - request?: ProviderV2.Request -} - -type ModelInput = Partial> & { - api?: (ProviderV2.Api & { id?: ModelV2.ID }) | { id: ModelV2.ID } - request?: ModelV2.Info["request"] -} - -export function provider(providerID: string, options?: ProviderInput) { - return new ProviderV2.Info({ - ...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)), - api: options?.api ?? { - type: "aisdk", - package: "test-provider", - }, - ...options, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function model(providerID: string, modelID: string, options?: ModelInput) { - return new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), - ...options, - api: - options?.api && "type" in options.api - ? { id: ModelV2.ID.make(modelID), ...options.api } - : { - id: ModelV2.ID.make(modelID), - ...options?.api, - type: "aisdk", - package: "test-provider", - }, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function withEnv(vars: Record, fx: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) - for (const [key, value] of Object.entries(vars)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - return previous - }), - () => fx(), - (previous) => - Effect.sync(() => { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - }), - ) -} - -export function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -export function expectPluginRegistered(ids: string[], id: string) { - expect(ids).toContain(PluginV2.ID.make(id)) -} diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index d54bf31342b..5e7a7c2d2bb 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -2,96 +2,98 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) }) +}) describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "kilo", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))), ) it.effect("applies legacy referer headers only to kilo", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - request: { headers: { Existing: "value" }, body: {} }, + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - draft.request = kilo.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("uses the exact legacy Kilo header casing and set", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const item = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) }) + yield* addPlugin() - const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))) - expect(result.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(result.request.headers).not.toHaveProperty("http-referer") - expect(result.request.headers).not.toHaveProperty("x-title") - expect(result.request.headers).not.toHaveProperty("X-Source") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty( + "http-referer", + ) + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source") }), ) it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - }) - const custom = provider("custom-kilo", { - api: { type: "aisdk", package: "kilo" }, - }) - catalog.provider.update(custom.id, (draft) => { - draft.api = custom.api + catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => { + provider.api = { type: "aisdk", package: "kilo" } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 456880c194d..0fc22c5235d 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -3,36 +3,28 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider, required } from "./provider-helper" -import { catalogHost, host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) }) +}) describe("LLMGatewayPlugin", () => { - const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { - const integrations = yield* Integration.Service - const catalog = yield* Catalog.Service - yield* plugin.add({ - ...LLMGatewayPlugin, - effect: LLMGatewayPlugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ), - }) - }) - it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "llmgateway", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))), ) it.effect("applies legacy referer headers only to enabled llmgateway", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service yield* integrations.transform((editor) => { @@ -40,43 +32,48 @@ describe("LLMGatewayPlugin", () => { editor.update(Integration.ID.make("openrouter"), () => {}) }) yield* catalog.transform((catalog) => { - const llmgateway = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(llmgateway.id, (draft) => { - draft.api = llmgateway.api - draft.request = llmgateway.request + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - yield* add(plugin) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-Source": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("does not apply legacy headers to a disabled llmgateway provider", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin) + const integrations = yield* Integration.Service + yield* integrations.transform((editor) => { + editor.update(Integration.ID.make("llmgateway"), () => {}) + }) yield* catalog.transform((catalog) => { - const item = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.disabled = true + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index ea3b3a67096..f09e0e62c70 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -1,18 +1,37 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: MistralPlugin.id, effect: MistralPlugin.effect(host) }) +}) describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, MistralPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -22,11 +41,14 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, MistralPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("mistral", "mistral-large"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "mistral" }, }, @@ -40,19 +62,22 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* addPlugin(plugin, MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) + ) const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -64,20 +89,19 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* addPlugin(plugin, MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) + ) yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-mistral", "mistral-large"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/mistral", options: { name: "custom-mistral" }, }, @@ -91,11 +115,23 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, MistralPlugin) + const sdk = { + languageModel: (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + }, + } + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }, {}, ) const language = result.language ?? sdk.languageModel(result.model.api.id) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index c5c986f6291..ee16e5a2bea 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -2,64 +2,66 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) }) +}) describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "nvidia", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))), ) it.effect("applies NVIDIA tracking headers only to nvidia", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const nvidia = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: { Existing: "value" }, body: {} }, + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(nvidia.id, (draft) => { - draft.api = nvidia.api - draft.request = nvidia.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("adds billing origin for custom NVIDIA endpoints", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: {}, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -69,24 +71,23 @@ describe("NvidiaPlugin", () => { it.effect("preserves an explicit NVIDIA billing origin header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, body: { baseURL: "https://integrate.api.nvidia.com/v1" }, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index 7e695c89c06..c0601c2ba33 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -1,23 +1,45 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) }) +}) describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const defaulted = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom" }, + }, {}, ) const disabled = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "custom", includeUsage: false }, }, @@ -31,11 +53,14 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", options: { name: "custom" }, }, @@ -49,20 +74,19 @@ describe("OpenAICompatiblePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* addPlugin(plugin, OpenAICompatiblePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + observed.push(event.sdk.languageModel("model").provider) }), - }) + ) yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-provider", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "custom-provider", baseURL: "https://example.com/v1" }, }, @@ -85,11 +109,14 @@ describe("OpenAICompatiblePlugin", () => { }), }), }) - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai" }, }, diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d41a856ce75..a9911d2cc8a 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,28 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper" -import { host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -function add(plugin: PluginV2.Interface, integrations: Integration.Interface) { - return plugin.add({ +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + const integrations = yield* Integration.Service + yield* plugin.add({ id: OpenAIPlugin.id, - effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe( - Effect.provideService(Integration.Service, integrations), - ), + effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)), }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } } describe("OpenAIPlugin", () => { it.effect("registers browser and headless ChatGPT OAuth methods", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ { id: Integration.MethodID.make("chatgpt-browser"), @@ -41,11 +63,14 @@ describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-openai", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai", options: { name: "custom-openai", apiKey: "test" }, }, @@ -58,10 +83,17 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openai" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -72,11 +104,12 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "alias", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, }), sdk: fakeSelectorSdk(calls), @@ -93,10 +126,17 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) @@ -106,17 +146,19 @@ describe("OpenAIPlugin", () => { it.effect("disables gpt-5-chat-latest during catalog transforms", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) yield* catalog.transform((catalog) => { - const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } }) + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) catalog.provider.update(item.id, (draft) => { draft.api = item.api }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) expect( required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled, @@ -126,14 +168,18 @@ describe("OpenAIPlugin", () => { it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) yield* catalog.transform((catalog) => { - const item = provider("custom-openai") - catalog.provider.update(item.id, () => {}) + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")), + api: { type: "aisdk", package: "test-provider" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 9fe4be97332..750b71463cf 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,45 +1,72 @@ import { describe, expect } from "bun:test" -import { Effect, Layer, Option } from "effect" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Credential } from "@opencode-ai/core/credential" -import { EventV2 } from "@opencode-ai/core/event" import { Integration } from "@opencode-ai/core/integration" -import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { it, model, provider, required, withEnv } from "./provider-helper" -import { catalogHost, host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) - -const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({ - ...OpencodePlugin, - effect: OpencodePlugin.effect(host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) })), -}) describe("OpencodePlugin", () => { it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) }), @@ -49,17 +76,23 @@ describe("OpencodePlugin", () => { it.effect("keeps free models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const free = model("opencode", "free", { cost: cost(0) }) - catalog.model.update(item.id, free.id, (draft) => { - draft.cost = [...free.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")), + api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" }, + cost: cost(0), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) }), @@ -69,17 +102,23 @@ describe("OpencodePlugin", () => { it.effect("treats output-only cost as free without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) - catalog.model.update(item.id, outputOnly.id, (draft) => { - draft.cost = [...outputOnly.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")), + api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" }, + cost: cost(0, 1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe( true, @@ -91,17 +130,23 @@ describe("OpencodePlugin", () => { it.effect("uses OPENCODE_API_KEY as credentials", () => withEnv({ OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -111,10 +156,8 @@ describe("OpencodePlugin", () => { it.effect("uses configured provider env vars as credentials", () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* plugin.add(pluginWithIntegrations(catalog, integrations)) yield* integrations.transform((editor) => { editor.method.update({ integrationID: Integration.ID.make("opencode"), @@ -122,13 +165,21 @@ describe("OpencodePlugin", () => { }) }) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -138,24 +189,29 @@ describe("OpencodePlugin", () => { it.effect("uses configured apiKey as credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode", { + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { apiKey: "configured" }, }, }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), }) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + catalog.provider.update(provider.id, (draft) => { + draft.request = provider.request + }) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -165,17 +221,23 @@ describe("OpencodePlugin", () => { it.effect("ignores non-opencode providers and models", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("openai") - catalog.provider.update(item.id, () => {}) - const paid = model("openai", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -206,8 +268,6 @@ describe("OpencodePlugin", () => { const selected = yield* catalog.model.small(providerID) expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) - }).pipe( - Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))), - ), + }), ) }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 49b5875b5e0..5761827c4ea 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -3,56 +3,59 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, expectPluginRegistered, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) }) +}) describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "openrouter", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))), ) it.effect("applies legacy referer headers only to openrouter", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api - item.request = openrouter.request + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({}) }), ) it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenRouterPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("openrouter", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "openrouter" }, }, @@ -62,7 +65,14 @@ describe("OpenRouterPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@openrouter/ai-sdk-provider", + options: { name: "custom" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -71,52 +81,37 @@ describe("OpenRouterPlugin", () => { it.effect("filters OpenRouter's gpt-5 chat alias", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } }) catalog.provider.update(ProviderV2.ID.openai, () => {}) - for (const item of [ - model("openrouter", "openai/gpt-5-chat"), - model("openrouter", "openai/gpt-5"), - model("openai", "openai/gpt-5-chat"), - ]) { - catalog.model.update(item.providerID, item.id, () => {}) - } + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {}) + catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) }) + yield* addPlugin() - expect( - required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))) - .enabled, - ).toBe(false) - expect( - required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled, - ).toBe(true) - expect( - required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled, - ).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe( + false, + ) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true) }), ) it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - required( - yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")), - ).enabled, + (yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))) + ?.enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index 35498d5e9e8..eeb00093ebf 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -1,18 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -22,11 +54,14 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("perplexity", "sonar"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/perplexity-compatible", options: { name: "perplexity" }, }, @@ -39,50 +74,40 @@ describe("PerplexityPlugin", () => { it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }, {}, ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("custom-perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-perplexity", "sonar"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/perplexity", options: { name: "custom-perplexity" }, }, {}, ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) @@ -90,11 +115,14 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 51103167b56..a6fe387186e 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,16 +1,54 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { Npm } from "@opencode-ai/core/npm" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" -import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" -import { host } from "./host" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const pluginWithNpm = { - id: SapAICorePlugin.id, - effect: Effect.gen(function* () { - yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service })) - }).pipe(Effect.provide(npmLayer)), +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href +const it = testEffect(PluginTestLayer) +const npm = Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), +}) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + for (const [key, value] of Object.entries(vars)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return previous + }), + effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) +} + +function model(providerID: string) { + return new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), + api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider }, + }) } describe("SapAICorePlugin", () => { @@ -20,11 +58,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("sap-ai-core", "sap-model"), + model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core", serviceKey: "service-key" }, }, @@ -46,11 +84,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("sap-ai-core", "sap-model"), + model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core", serviceKey: "option-service-key" }, }, @@ -68,10 +106,10 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", - { model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } }, + { model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } }, {}, ) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() @@ -83,7 +121,7 @@ describe("SapAICorePlugin", () => { it.effect("uses the callable SDK for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { languageModel() { throw new Error("SAP AI Core should call the SDK directly") @@ -91,7 +129,7 @@ describe("SapAICorePlugin", () => { }) const language = yield* plugin.trigger( "aisdk.language", - { model: model("sap-ai-core", "sap-model"), sdk, options: {} }, + { model: model("sap-ai-core"), sdk, options: {} }, {}, ) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) @@ -104,11 +142,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("openai", "sap-model"), + model: model("openai"), package: fixtureProvider, options: { name: "openai", serviceKey: "service-key" }, }, @@ -117,7 +155,7 @@ describe("SapAICorePlugin", () => { const language = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "sap-model"), + model: model("openai"), sdk: () => { throw new Error("SAP AI Core should ignore other providers") }, diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 5de7ae06588..c376a6947c6 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,18 +1,48 @@ import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" -import { addPlugin, expectPluginRegistered, it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => Effect.sync(() => { - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "snowflake-cortex", - ) - const ids = ProviderPlugins.map((p) => p.id as string) + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex")) + const ids = ProviderPlugins.map((p) => p.id) expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) }), ) @@ -20,10 +50,17 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "openai" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -34,11 +71,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, @@ -53,11 +96,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", @@ -76,11 +125,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, @@ -95,11 +150,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", @@ -118,27 +179,23 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const captured: Record[] = [] - yield* addPlugin(plugin, SnowflakeCortexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - captured.push({ ...evt.options }) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("snowflake-cortex"), + ModelV2.ID.make("claude-sonnet-4-6"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, {}, ) - expect(captured[0]?.includeUsage).toBe(true) + expect(result.options.includeUsage).toBe(true) }), ), ) diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index 19757e126eb..b780124a6b0 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -1,17 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -21,12 +54,15 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("togetherai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/@ai-sdk/togetherai-provider.js", options: { name: "togetherai" }, }, @@ -36,7 +72,14 @@ describe("TogetherAIPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -46,29 +89,22 @@ describe("TogetherAIPlugin", () => { it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* addPlugin(plugin, TogetherAIPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), - }), - }) + yield* addPlugin() - yield* plugin.trigger( + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-togetherai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/togetherai", options: { name: "custom-togetherai" }, }, {}, ) - expect(observed).toEqual(["togetherai.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") }), ) @@ -76,12 +112,22 @@ describe("TogetherAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("togetherai"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + type: "aisdk", + package: "test-provider", + }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index 148a30ee46e..639543af5bc 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -1,17 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: VenicePlugin.id, effect: VenicePlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "venice" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -21,39 +54,35 @@ describe("VenicePlugin", () => { it.effect("uses the model provider ID as the bundled Venice SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* addPlugin(plugin, VenicePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), - }), - }) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-venice", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "venice-ai-sdk-provider", options: { name: "custom-venice", apiKey: "test" }, }, {}, ) expect(result.sdk).toBeDefined() - expect(observed).toEqual(["custom-venice.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") }), ) it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const similar = yield* plugin.trigger( "aisdk.sdk", { - model: model("venice", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/venice-ai-sdk-provider.js", options: { name: "venice" }, }, @@ -61,7 +90,14 @@ describe("VenicePlugin", () => { ) const other = yield* plugin.trigger( "aisdk.sdk", - { model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "venice" }, + }, {}, ) expect(similar.sdk).toBeUndefined() @@ -73,10 +109,17 @@ describe("VenicePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index c958d139e46..b3cb5f28957 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -1,28 +1,34 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) }) +}) describe("VercelPlugin", () => { it.effect("applies legacy lower-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, VercelPlugin) yield* catalog.transform((catalog) => { - const item = provider("vercel", { - api: { type: "aisdk", package: "@ai-sdk/vercel" }, - request: { headers: { Existing: "1" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + provider.request.headers.Existing = "1" }) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({ Existing: "1", "http-referer": "https://opencode.ai/", "x-title": "opencode", @@ -32,19 +38,17 @@ describe("VercelPlugin", () => { it.effect("does not add legacy upper-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, VercelPlugin) - yield* catalog.transform((catalog) => { - const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - }) - }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( + yield* catalog.transform((catalog) => + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + }), + ) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( "HTTP-Referer", ) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( "X-Title", ) }), @@ -53,10 +57,17 @@ describe("VercelPlugin", () => { it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VercelPlugin) + yield* addPlugin() const event = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, + }), + package: "@ai-sdk/vercel", + options: { name: "custom-vercel" }, + }, {}, ) expect(event.sdk).toBeDefined() @@ -66,11 +77,10 @@ describe("VercelPlugin", () => { it.effect("ignores non-Vercel providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, VercelPlugin) - yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {})) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 4ac5cf34f18..a978381dea5 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,37 +1,66 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, fakeSelectorSdk } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) +const it = testEffect(PluginTestLayer) -const model = new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { - id: ModelV2.ID.make("grok-4"), - type: "aisdk", - package: "@ai-sdk/xai", - }, +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) }) }) +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} + describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", - { model, package: "@ai-sdk/openai-compatible", options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/openai-compatible", + options: {}, + }, {}, ) - const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {}) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }, + {}, + ) expect(ignored.sdk).toBeUndefined() expect(typeof result.sdk?.responses).toBe("function") @@ -41,32 +70,22 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] + yield* addPlugin() - yield* addPlugin(plugin, XAIPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("xai-sdk-name-observer"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (!evt.sdk) return - providers.push(evt.sdk.responses("grok-4").provider) - }), - } - }), - }) - - yield* plugin.trigger( + const result = yield* plugin.trigger( "aisdk.sdk", { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), package: "@ai-sdk/xai", options: {}, }, {}, ) - expect(providers).toEqual(["xai.responses"]) + expect(result.sdk.responses("grok-4").provider).toBe("xai.responses") }), ) @@ -75,11 +94,14 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -96,11 +118,14 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 4bfdc7b0e14..3313cd048ae 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -2,34 +2,44 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "zenmux", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))), ) it.effect("applies the exact legacy Zenmux headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) @@ -38,19 +48,18 @@ describe("ZenmuxPlugin", () => { it.effect("merges legacy Zenmux headers with existing headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers.Existing = "value" }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ Existing: "value", @@ -62,22 +71,18 @@ describe("ZenmuxPlugin", () => { it.effect("lets configured Zenmux legacy headers override defaults", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", @@ -88,20 +93,13 @@ describe("ZenmuxPlugin", () => { it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("openrouter", { - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts new file mode 100644 index 00000000000..8a7fd8ca7f2 --- /dev/null +++ b/packages/core/test/preload.ts @@ -0,0 +1 @@ +process.env.OPENCODE_DB = ":memory:" diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 47f2176c376..8c01e92c501 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -16,17 +16,16 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer)) -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) const copyLayer = ProjectCopy.layer.pipe( - Layer.provide(databaseLayer), - Layer.provide(directoriesLayer), - Layer.provide(eventLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectDirectories.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), ) -const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer)) +const it = testEffect( + Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer), +) function abs(input: string) { return AbsolutePath.make(input) diff --git a/packages/core/test/project-directories.test.ts b/packages/core/test/project-directories.test.ts index c1d2d8801f9..491c2e26022 100644 --- a/packages/core/test/project-directories.test.ts +++ b/packages/core/test/project-directories.test.ts @@ -8,10 +8,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, directories)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer)) const projectID = Project.ID.make("project-directories") const directory = AbsolutePath.make("/tmp/project-directories") diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 45fba0d18ff..7f0e9389a8a 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -13,19 +13,8 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) const it = testEffect( - Layer.mergeAll( - ProjectV2.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(directoriesLayer), - Layer.provide(databaseLayer), - ), - databaseLayer, - directoriesLayer, - ), + Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer), ) function remoteID(remote: string) { diff --git a/packages/core/test/question.test.ts b/packages/core/test/question.test.ts index 57bf399669a..3ad95456f78 100644 --- a/packages/core/test/question.test.ts +++ b/packages/core/test/question.test.ts @@ -6,10 +6,8 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, questions)) +const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions)) const sessionID = SessionV2.ID.make("ses_question_test") const question: QuestionV2.Info = { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 96c7c9bd212..6fd80c60da1 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -25,8 +25,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" import { tmpdir } from "./fixture/tmpdir" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ @@ -35,17 +33,23 @@ const projects = Layer.succeed( commit: () => Effect.void, }), ) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(projects), Layer.provide(SessionExecution.noopLayer), ) const it = testEffect( - Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions), + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + projects, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const id = SessionV2.ID.create() diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index df9ac731b01..a0894d07eb0 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -21,10 +21,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer)) const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } @@ -113,10 +110,10 @@ describe("SessionProjector", () => { }).pipe( Effect.provide( SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), Layer.provide(Project.defaultLayer), - Layer.provide(SessionStore.layer.pipe(Layer.provide(database))), + Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionExecution.noopLayer), ), ), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index b2aec228e55..c84a3ab304e 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -18,10 +18,6 @@ import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode- import { SessionStore } from "@opencode-ai/core/session/store" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const executionCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = [] const interruptSeqs: Array = [] @@ -47,13 +43,22 @@ const execution = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) -const it = testEffect(Layer.mergeAll(database, events, projector, store, execution, sessions)) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + execution, + sessions, + ), +) const sessionID = SessionV2.ID.make("ses_prompt_test") const messageID = SessionMessage.ID.create() diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index a03ec77f031..50e60a3616a 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -268,7 +268,7 @@ describe("SessionRunnerModel", () => { it.effect("prefers stored credentials over configured auth", () => Effect.gen(function* () { - const credential = new Credential.Stored({ + const credential = new Credential.Info({ id: Credential.ID.create(), integrationID: Integration.ID.make("test-provider"), label: "Work", diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index e8da56a3a5c..91d7a24475d 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -32,10 +32,6 @@ import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const cassette = process.env.RECORD === "true" ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { @@ -74,9 +70,9 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const runner = SessionRunnerLLM.defaultLayer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(client), Layer.provide(registry), Layer.provide(models), @@ -101,18 +97,18 @@ const execution = Layer.effect( ), ).pipe(Layer.provide(coordinator)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) const it = testEffect( Layer.mergeAll( - database, - events, - projector, - store, + Database.defaultLayer, + EventV2.defaultLayer, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, executor, client, permission, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index eb5ccb277df..862bb56d33d 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -56,11 +56,7 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) +const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) const requests: LLMRequest[] = [] let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined @@ -235,9 +231,9 @@ const config = Layer.succeed( }), ) const runner = SessionRunnerLLM.layer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(client), Layer.provide(registry), Layer.provide(models), @@ -262,19 +258,19 @@ const execution = Layer.effect( ), ).pipe(Layer.provide(coordinator)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) const it = testEffect( Layer.mergeAll( - database, - events, + Database.defaultLayer, + EventV2.defaultLayer, questions, - projector, - store, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, client, permission, applications, diff --git a/packages/core/test/session-todo.test.ts b/packages/core/test/session-todo.test.ts index d1d656af38a..ff10c405001 100644 --- a/packages/core/test/session-todo.test.ts +++ b/packages/core/test/session-todo.test.ts @@ -11,10 +11,7 @@ import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql" import { SessionTodo } from "@opencode-ai/core/session/todo" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, todos)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer)) const sessionID = SessionV2.ID.make("ses_todo_test") const setup = Effect.gen(function* () { diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 09cc159a20e..85dcb604a9b 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -16,10 +16,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer)) const timestamp = DateTime.makeUnsafe(1) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 57a354fc7c0..d8f96a58036 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -404,7 +404,7 @@ test("keeps the locked edit schema, semantics docstring, and deferred TODOs visi expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.", diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts index 2bc17541634..c897786e891 100644 --- a/packages/core/test/tool-read-filesystem.test.ts +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -6,7 +6,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { testEffect } from "./lib/effect" -const it = testEffect(FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))) +const it = testEffect(Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)) const fixture = Effect.gen(function* () { const fs = yield* FSUtil.Service const files = yield* FileSystem.FileSystem diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index c85b7e35913..fcbec061b2d 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect } from "bun:test" +import path from "path" import { Effect, Exit, Layer, PlatformError } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" @@ -19,7 +20,7 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to const assertions: PermissionV2.AssertInput[] = [] const missingPath = "__missing_read_target__.txt" -const missingAbsolutePath = `${process.cwd()}/${missingPath}` +const missingAbsolutePath = path.join(process.cwd(), missingPath) const readCalls: { input: AbsolutePath page: ReadToolFileSystem.PageInput @@ -164,7 +165,12 @@ describe("ReadTool", () => { }, }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) - expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/README.md`), page: {} }]) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "README.md")), + page: { offset: undefined, limit: undefined }, + }, + ]) }), ) @@ -193,7 +199,12 @@ describe("ReadTool", () => { { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, ], }) - expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/pixel.png`), page: {} }]) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")), + page: { offset: undefined, limit: undefined }, + }, + ]) const settled = yield* settleTool(registry, { sessionID, @@ -449,7 +460,7 @@ describe("ReadTool", () => { }), ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } }, + { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } }, ]) }), ) @@ -589,7 +600,7 @@ describe("ReadTool", () => { value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/large.txt`), page: { offset: 2, limit: 1 } }, + { input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } }, ]) }), ) diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index c8d1799010a..38ba1900aaf 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -32,12 +32,15 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos)) -const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) +const tool = TodoWriteTool.layer.pipe( + Layer.provide(registry), + Layer.provide(permission), + Layer.provide(SessionTodo.defaultLayer), +) +const it = testEffect( + Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool), +) const setup = Effect.gen(function* () { assertions.length = 0 diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index de5c7c264aa..a81b32d37d3 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -279,7 +279,7 @@ test("keeps the locked write schema, semantics docstring, and deferred UX TODOs expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.", diff --git a/turbo.json b/turbo.json index 220cd96e584..c9255902c50 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,10 @@ "outputs": [], "passThroughEnv": ["*"] }, + "@opencode-ai/core#test": { + "dependsOn": ["^build"], + "outputs": [] + }, "@opencode-ai/app#test": { "dependsOn": ["^build"], "outputs": [] From cd292a4ecbaeedd19239edddca77f86d9727c9ae Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 22 Jun 2026 04:17:10 +0000 Subject: [PATCH 061/112] chore: generate --- packages/core/src/database/schema.gen.ts | 32 +++++++++--- packages/core/src/integration.ts | 12 +++-- packages/core/test/credential.test.ts | 36 ++++++------- packages/core/test/integration.test.ts | 5 +- .../plugin/provider-amazon-bedrock.test.ts | 20 ++------ .../test/plugin/provider-anthropic.test.ts | 5 +- .../provider-azure-cognitive-services.test.ts | 15 ++---- .../provider-cloudflare-ai-gateway.test.ts | 50 ++++--------------- .../core/test/plugin/provider-gateway.test.ts | 5 +- .../plugin/provider-github-copilot.test.ts | 5 +- .../test/plugin/provider-sap-ai-core.test.ts | 6 +-- .../plugin/provider-snowflake-cortex.test.ts | 25 ++-------- .../core/test/plugin/provider-vercel.test.ts | 4 +- packages/core/test/project.test.ts | 4 +- 14 files changed, 78 insertions(+), 146 deletions(-) diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 5190e58384a..5c044ec60f9 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -241,16 +241,32 @@ export default { `) yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`) - yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`) - yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`) + yield* tx.run( + `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`, + ) + yield* tx.run( + `CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`, + ) yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) - yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`) - yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`) - yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`) - yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`) - yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`) - yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`) + yield* tx.run( + `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`, + ) + yield* tx.run( + `CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`, + ) + yield* tx.run( + `CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`, + ) yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`) yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index f9081525b23..03192921b93 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -352,11 +352,13 @@ export const locationLayer = Layer.effect( }) const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => { - const credentials = saved.map((credential) => ({ - type: "credential" as const, - id: credential.id, - label: credential.label, - })).toReversed() + const credentials = saved + .map((credential) => ({ + type: "credential" as const, + id: credential.id, + label: credential.label, + })) + .toReversed() const env = (entry?.methods ?? []) .filter((method) => method.type === "env") .flatMap((method) => method.names.filter((name) => process.env[name])) diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index 6c7f08e112a..8c1901acd20 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -9,27 +9,27 @@ const it = testEffect(Credential.defaultLayer) describe("Credential", () => { it.effect("stores, updates, lists, and removes credentials", () => Effect.gen(function* () { - const credentials = yield* Credential.Service - const integrationID = Integration.ID.make("openai") - const created = yield* credentials.create({ - integrationID, - label: "Work", - value: new Credential.Key({ type: "key", key: "secret" }), - }) + const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("openai") + const created = yield* credentials.create({ + integrationID, + label: "Work", + value: new Credential.Key({ type: "key", key: "secret" }), + }) - expect(yield* credentials.list(integrationID)).toEqual([created]) - yield* credentials.update(created.id, { label: "Personal" }) - expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal") + expect(yield* credentials.list(integrationID)).toEqual([created]) + yield* credentials.update(created.id, { label: "Personal" }) + expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal") - const replacement = yield* credentials.create({ - integrationID, - label: "Replacement", - value: new Credential.Key({ type: "key", key: "replacement" }), - }) - expect(yield* credentials.list(integrationID)).toEqual([replacement]) + const replacement = yield* credentials.create({ + integrationID, + label: "Replacement", + value: new Credential.Key({ type: "key", key: "replacement" }), + }) + expect(yield* credentials.list(integrationID)).toEqual([replacement]) - yield* credentials.remove(replacement.id) - expect(yield* credentials.list(integrationID)).toEqual([]) + yield* credentials.remove(replacement.id) + expect(yield* credentials.list(integrationID)).toEqual([]) }), ) }) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index c95dbdf381b..f4a1ccd8358 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -7,10 +7,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { testEffect } from "./lib/effect" const it = testEffect( - Integration.locationLayer.pipe( - Layer.provideMerge(Credential.defaultLayer), - Layer.provideMerge(EventV2.defaultLayer), - ), + Integration.locationLayer.pipe(Layer.provideMerge(Credential.defaultLayer), Layer.provideMerge(EventV2.defaultLayer)), ) describe("Integration", () => { diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index 7b3cea5c1a3..1a2512485b0 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -179,10 +179,7 @@ describe("AmazonBedrockPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.amazonBedrock, - ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", @@ -389,10 +386,7 @@ describe("AmazonBedrockPlugin", () => { "aisdk.language", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.amazonBedrock, - ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), api: { id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), type: "aisdk", @@ -450,10 +444,7 @@ describe("AmazonBedrockPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.amazonBedrock, - ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", @@ -515,10 +506,7 @@ describe("AmazonBedrockPlugin", () => { "aisdk.language", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.amazonBedrock, - ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), api: { id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), type: "aisdk", diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 389fa8c6f85..ba3a33915b0 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -64,10 +64,7 @@ describe("AnthropicPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("custom-anthropic"), - ModelV2.ID.make("claude-sonnet-4-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, }), package: "@ai-sdk/anthropic", diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 222e25e9b99..2c1c7ec8788 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -120,10 +120,7 @@ describe("AzureCognitiveServicesPlugin", () => { "aisdk.language", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("azure-cognitive-services"), - ModelV2.ID.make("deployment"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, }), sdk: fakeSelectorSdk(calls), @@ -144,10 +141,7 @@ describe("AzureCognitiveServicesPlugin", () => { "aisdk.language", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("azure-cognitive-services"), - ModelV2.ID.make("deployment"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, }), sdk: fakeSelectorSdk(calls), @@ -197,10 +191,7 @@ describe("AzureCognitiveServicesPlugin", () => { "aisdk.language", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("azure-cognitive-services"), - ModelV2.ID.make("chat-deployment"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, }), sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 34e6261d318..31ce4448f01 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -116,10 +116,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -143,10 +140,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -193,10 +187,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -228,10 +219,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -271,10 +259,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -308,10 +293,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -336,10 +318,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -365,10 +344,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -400,10 +376,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "ai-gateway-provider", @@ -467,10 +440,7 @@ describe("CloudflareAIGatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("openai/gpt-5"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, }), package: "@ai-sdk/openai-compatible", diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 3bd6d24963c..619e184d83e 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -66,10 +66,7 @@ describe("GatewayPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("vercel"), - ModelV2.ID.make("anthropic/claude-sonnet-4"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), api: { id: ModelV2.ID.make("anthropic/claude-sonnet-4"), type: "aisdk", diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index f7ca619eae0..b8f615f9337 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -169,10 +169,7 @@ describe("GithubCopilotPlugin", () => { "aisdk.language", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("github-copilot"), - ModelV2.ID.make("gpt-5-mini-2025-08-07"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, }), sdk: fakeSelectorSdk(calls), diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index a6fe387186e..6892aaf6aa1 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -127,11 +127,7 @@ describe("SapAICorePlugin", () => { throw new Error("SAP AI Core should call the SDK directly") }, }) - const language = yield* plugin.trigger( - "aisdk.language", - { model: model("sap-ai-core"), sdk, options: {} }, - {}, - ) + const language = yield* plugin.trigger("aisdk.language", { model: model("sap-ai-core"), sdk, options: {} }, {}) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) }), ) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index c376a6947c6..ca839fff519 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -76,10 +76,7 @@ describe("SnowflakeCortexPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("snowflake-cortex"), - ModelV2.ID.make("claude-sonnet-4-6"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, }), package: "@ai-sdk/openai-compatible", @@ -101,10 +98,7 @@ describe("SnowflakeCortexPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("snowflake-cortex"), - ModelV2.ID.make("claude-sonnet-4-6"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, }), package: "@ai-sdk/openai-compatible", @@ -130,10 +124,7 @@ describe("SnowflakeCortexPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("snowflake-cortex"), - ModelV2.ID.make("claude-sonnet-4-6"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, }), package: "@ai-sdk/openai-compatible", @@ -155,10 +146,7 @@ describe("SnowflakeCortexPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("snowflake-cortex"), - ModelV2.ID.make("claude-sonnet-4-6"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, }), package: "@ai-sdk/openai-compatible", @@ -184,10 +172,7 @@ describe("SnowflakeCortexPlugin", () => { "aisdk.sdk", { model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("snowflake-cortex"), - ModelV2.ID.make("claude-sonnet-4-6"), - ), + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, }), package: "@ai-sdk/openai-compatible", diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index b3cb5f28957..5abc737dd02 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -48,9 +48,7 @@ describe("VercelPlugin", () => { expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( "HTTP-Referer", ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( - "X-Title", - ) + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title") }), ) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 7f0e9389a8a..8c52f4a98ae 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -13,9 +13,7 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect( - Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer), -) +const it = testEffect(Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer)) function remoteID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) From c7efbe6fc08fa42a5e5a3485a0127867064fba71 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 22 Jun 2026 13:20:43 +0200 Subject: [PATCH 062/112] run: inline files for attached servers (#33317) --- packages/opencode/src/cli/cmd/run.ts | 45 +++- .../opencode/test/cli/run/run-process.test.ts | 255 +++++++++++++++++- packages/opencode/test/lib/cli-process.ts | 67 ++++- 3 files changed, 351 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 6f4508cb0b0..958632776bd 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,4 +1,5 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { FSUtil } from "@opencode-ai/core/fs-util" // CLI entry point for `opencode run`. // // Handles three modes: @@ -15,6 +16,7 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { open } from "node:fs/promises" import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" @@ -54,6 +56,8 @@ type FilePart = { mime: string } +const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 + type Inline = { icon: string title: string @@ -337,11 +341,48 @@ export const RunCommand = effectCmd({ process.exit(1) } - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const stat = Filesystem.stat(resolvedPath) + const isDirectory = stat?.isDirectory() ?? false + if (args.attach && isDirectory) { + UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`) + process.exit(1) + } + + const content = await (async () => { + if (!args.attach) return + const handle = await open(resolvedPath, "r") + try { + const opened = await handle.stat() + if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { + UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`) + process.exit(1) + } + if (opened.size === 0) return Buffer.alloc(0) + const buffer = Buffer.alloc(Number(opened.size)) + let offset = 0 + while (offset < buffer.length) { + const read = await handle.read(buffer, offset, buffer.length - offset, offset) + if (read.bytesRead === 0) break + offset += read.bytesRead + } + return buffer.subarray(0, offset) + } finally { + await handle.close() + } + })() + const detected = FSUtil.mimeType(resolvedPath) + const text = content?.toString("utf8") + const mime = !args.attach + ? isDirectory + ? "application/x-directory" + : "text/plain" + : content && text !== undefined && Buffer.from(text, "utf8").equals(content) + ? "text/plain" + : detected files.push({ type: "file", - url: pathToFileURL(resolvedPath).href, + url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href, filename: path.basename(resolvedPath), mime, }) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 00d2e64b377..b15cfc019d6 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -5,6 +5,7 @@ // `OPENCODE_CONFIG_CONTENT` providing the test provider config inline. import { describe, expect } from "bun:test" import { Effect } from "effect" +import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" describe("opencode run (non-interactive subprocess)", () => { @@ -17,7 +18,46 @@ describe("opencode run (non-interactive subprocess)", () => { yield* llm.text("hello from the test llm") const result = yield* opencode.run("say hi") opencode.expectExit(result, 0) - expect(result.stdout).toContain("hello from the test llm") + expect(result.stdout).toBe("hello from the test llm\n") + }), + 60_000, + ) + + cliIt.concurrent( + "prints each completed text part in order around a tool continuation", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().text(" before tool ").tool("bash", { + command: "printf tool-output", + description: "Print deterministic output", + }), + ) + yield* llm.text(" after tool ") + + const result = yield* opencode.run("use a tool", { + extraArgs: ["--dangerously-skip-permissions"], + }) + + opencode.expectExit(result, 0) + expect(result.stdout).toBe("before tool\nafter tool\n") + }), + 60_000, + ) + + cliIt.concurrent( + "prints reasoning before text only with --thinking", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.reason(" considering ", { text: " answer " }) + const thinking = yield* opencode.run("think", { extraArgs: ["--thinking"] }) + opencode.expectExit(thinking, 0) + expect(thinking.stdout).toBe("Thinking: considering\nanswer\n") + + yield* llm.reason("hidden", { text: "visible" }) + const plain = yield* opencode.run("think again") + opencode.expectExit(plain, 0) + expect(plain.stdout).toBe("visible\n") }), 60_000, ) @@ -41,19 +81,24 @@ describe("opencode run (non-interactive subprocess)", () => { 30_000, ) - // Locks in the current behavior: when the LLM stream errors mid-response - // (the prompt was accepted, then the upstream provider failed), opencode - // emits a session.error event and the process exits 0 today. - // - // This is debatable — a future cleanup might flip it to exit 1. If you're - // changing this expectation, do it deliberately and say so in the PR. + // The test provider's SSE error item is interpreted by the SDK as an unknown + // finish, not a fatal provider/session error. Lock that distinction in so it + // is not accidentally used as the failure compatibility oracle. cliIt.concurrent( - "mid-stream LLM error still exits 0 today (contract lock-in)", + "unknown stream finish preserves partial output and exits 0", ({ llm, opencode }) => Effect.gen(function* () { + yield* llm.push( + reply().text("partial response").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) yield* llm.fail("upstream provider exploded mid-stream") const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 }) expect(result.exitCode).toBe(0) + expect(result.stdout).toBe("partial response\n") + expect(result.stderr).not.toContain("upstream provider exploded mid-stream") }), 60_000, ) @@ -75,10 +120,198 @@ describe("opencode run (non-interactive subprocess)", () => { expect(typeof evt.type).toBe("string") expect(typeof evt.sessionID).toBe("string") } - // At least one `text` event should appear with the LLM's response. - const text = events.find((e) => e.type === "text") - expect(text).toBeDefined() + expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"]) + expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([ + { type: "step_start", part: expect.objectContaining({ type: "step-start" }) }, + { + type: "text", + part: expect.objectContaining({ type: "text", text: "structured output" }), + }, + { type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) }, + ]) + expect(result.stdout.endsWith("\n")).toBe(true) + expect(result.stdout.split("\n").slice(0, -1).every((line) => line.length > 0)).toBe(true) }), 60_000, ) + + cliIt.concurrent( + "--format json emits a pure error record for a rejected prompt request", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.run("use an unknown model", { + model: "test/nonexistent-model", + format: "json", + }) + + expect(result.exitCode).not.toBe(0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.map((event) => event.type)).toEqual(["error"]) + expect(events[0]).toEqual({ + type: "error", + timestamp: expect.any(Number), + sessionID: expect.any(String), + error: expect.any(Object), + }) + expect(result.stdout.split("\n").filter(Boolean)).toHaveLength(1) + }), + 30_000, + ) + + cliIt.concurrent( + "--format json preserves reasoning, tool, and continuation ordering", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().reason("reasoning").text("before").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) + yield* llm.text("after") + + const result = yield* opencode.run("exercise json records", { + format: "json", + extraArgs: ["--thinking", "--dangerously-skip-permissions"], + }) + + expect(result.exitCode).toBe(0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.map((event) => event.type)).toEqual([ + "step_start", + "reasoning", + "text", + "tool_use", + "step_finish", + "step_start", + "text", + "step_finish", + ]) + expect(events.find((event) => event.type === "reasoning")?.part).toEqual( + expect.objectContaining({ type: "reasoning", text: "reasoning" }), + ) + expect(events.find((event) => event.type === "tool_use")?.part).toEqual( + expect.objectContaining({ type: "tool", tool: "bash", state: expect.objectContaining({ status: "completed" }) }), + ) + expect(result.stdout.split("\n").slice(0, -1).every((line) => line.startsWith("{"))).toBe(true) + }), + 60_000, + ) + + cliIt.concurrent( + "--format json records partial output for an unknown stream finish", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().text("partial json").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) + yield* llm.fail("provider failed") + const result = yield* opencode.run("fail after output", { format: "json" }) + + const events = opencode.parseJsonEvents(result.stdout) + expect(result.exitCode).toBe(0) + expect(events.map((event) => event.type)).toEqual([ + "step_start", + "text", + "tool_use", + "step_finish", + "step_start", + "step_finish", + ]) + expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" })) + expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" })) + }), + 60_000, + ) + + cliIt.concurrent( + "rejects requested permissions by default and allows them with the dangerous flag", + ({ home, llm, opencode }) => + Effect.gen(function* () { + yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" }) + yield* llm.text("continued after rejection") + const denied = yield* opencode.run("request permission", { permission: { bash: "ask" } }) + opencode.expectExit(denied, 0) + expect(denied.stderr).toContain("permission requested: bash") + expect(denied.stdout).toBe("") + + yield* llm.reset + yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" }) + yield* llm.text("continued after approval") + const allowed = yield* opencode.run("request permission", { + permission: { bash: "ask" }, + extraArgs: ["--dangerously-skip-permissions"], + }) + opencode.expectExit(allowed, 0) + expect(allowed.stderr).not.toContain("permission requested: bash") + expect(allowed.stdout).toContain("continued after approval") + + yield* llm.reset + yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" }) + yield* llm.text("continued after explicit denial") + const explicitlyDenied = yield* opencode.run("request denied permission", { + permission: { bash: "deny" }, + extraArgs: ["--dangerously-skip-permissions"], + }) + opencode.expectExit(explicitlyDenied, 0) + expect(explicitlyDenied.stdout).toContain("continued after explicit denial") + expect(yield* Effect.promise(() => Bun.file(`${home}/explicitly-denied`).exists())).toBe(false) + }), + 60_000, + ) + + cliIt.live( + "attach mode sends client-local file contents without a shared path", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const source = `${home}/client-only.txt` + const sentinel = "client-only attachment sentinel" + yield* Effect.promise(() => Bun.write(source, sentinel)) + yield* llm.text("attachment received") + const server = yield* opencode.serve() + + const result = yield* opencode.run("read the attachment", { + extraArgs: ["--attach", server.url, `--file=${source}`, "--"], + }) + + opencode.expectExit(result, 0) + const input = JSON.stringify(yield* llm.inputs) + expect(input).toContain(sentinel) + expect(input).not.toContain(`file://${source}`) + }), + 60_000, + ) + + cliIt.concurrent( + "attach mode rejects local directories before prompt admission", + ({ home, opencode }) => + Effect.gen(function* () { + const result = yield* opencode.run("read the directory", { + extraArgs: ["--attach", "http://127.0.0.1:1", `--file=${home}`, "--"], + }) + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain("Cannot attach local directory without a shared filesystem") + }), + 30_000, + ) + + cliIt.live( + "SIGINT interrupts an active non-interactive run without leaking the process", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.hang + const run = yield* opencode.startRun("wait forever") + yield* llm.wait(1) + run.interrupt() + const result = yield* run.result + + expect(result.exitCode).not.toBe(0) + expect(result.durationMs).toBeLessThan(30_000) + }), + 30_000, + ) }) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index d40a4d7f63e..fa63156d825 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -82,6 +82,11 @@ export type RunResult = { readonly durationMs: number } +export type RunHandle = { + readonly interrupt: () => void + readonly result: Effect.Effect +} + export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record } // Typed equivalent of constructing argv for `opencode run`. New flags should @@ -92,6 +97,7 @@ export type RunOpts = SpawnOpts & { readonly format?: "default" | "json" readonly command?: string readonly printLogs?: boolean + readonly permission?: Record readonly extraArgs?: string[] } @@ -147,6 +153,7 @@ export type AcpHandle = { export type OpencodeCli = { // High-level: run a single prompt against the test model. Short-lived. readonly run: (message: string, opts?: RunOpts) => Effect.Effect + readonly startRun: (message: string, opts?: RunOpts) => Effect.Effect // Spawn `opencode serve` and wait until it's listening. Long-lived: the // returned handle is killed when the caller's Scope closes. Fails if the // listening line doesn't appear within `readyTimeoutMs`. @@ -236,7 +243,7 @@ export function withCliFixture( } }) - const run = (message: string, opts?: RunOpts): Effect.Effect => { + const runArgs = (message: string, opts?: RunOpts) => { const argv: string[] = ["run"] if (opts?.printLogs) argv.push("--print-logs") argv.push("--model", opts?.model ?? testModelID) @@ -245,9 +252,63 @@ export function withCliFixture( if (opts?.command) argv.push("--command", opts.command) if (opts?.extraArgs) argv.push(...opts.extraArgs) argv.push(message) - return spawn(argv, opts) + return argv } + const runOpts = (opts?: RunOpts): SpawnOpts | undefined => { + if (!opts?.permission) return opts + return { + ...opts, + env: { + ...opts.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...testProviderConfig(llm.url), + permission: opts.permission, + }), + }, + } + } + + const run = (message: string, opts?: RunOpts): Effect.Effect => { + return spawn( + runArgs(message, opts), + runOpts(opts), + ) + } + + const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) { + const start = Date.now() + const options = runOpts(opts) + const proc = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], { + cwd: home, + env: { ...process.env, ...env, ...options?.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ), + (child) => + Effect.promise(() => { + child.kill() + return child.exited + }).pipe(Effect.ignore), + ) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() + + return { + interrupt: () => proc.kill("SIGINT"), + result: Effect.promise(async () => ({ + exitCode: await proc.exited, + stdout: await stdout, + stderr: await stderr, + durationMs: Date.now() - start, + })), + } satisfies RunHandle + }) + const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) { const argv = ["serve"] // Default port 0 — let the OS pick a free port, parse the actual one @@ -401,7 +462,7 @@ export function withCliFixture( } satisfies AcpHandle }) - const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents } + const opencode: OpencodeCli = { run, startRun, serve, acp, spawn, expectExit, parseJsonEvents } return yield* fn({ llm, home, opencode }) // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient` From 4ecc3ac6535316c481982c169ad943ceae91a44e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 22 Jun 2026 11:22:22 +0000 Subject: [PATCH 063/112] chore: generate --- .../opencode/test/cli/run/run-process.test.ts | 20 ++++++++++++++++--- packages/opencode/test/lib/cli-process.ts | 5 +---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index b15cfc019d6..bd5847e2723 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -130,7 +130,12 @@ describe("opencode run (non-interactive subprocess)", () => { { type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) }, ]) expect(result.stdout.endsWith("\n")).toBe(true) - expect(result.stdout.split("\n").slice(0, -1).every((line) => line.length > 0)).toBe(true) + expect( + result.stdout + .split("\n") + .slice(0, -1) + .every((line) => line.length > 0), + ).toBe(true) }), 60_000, ) @@ -191,9 +196,18 @@ describe("opencode run (non-interactive subprocess)", () => { expect.objectContaining({ type: "reasoning", text: "reasoning" }), ) expect(events.find((event) => event.type === "tool_use")?.part).toEqual( - expect.objectContaining({ type: "tool", tool: "bash", state: expect.objectContaining({ status: "completed" }) }), + expect.objectContaining({ + type: "tool", + tool: "bash", + state: expect.objectContaining({ status: "completed" }), + }), ) - expect(result.stdout.split("\n").slice(0, -1).every((line) => line.startsWith("{"))).toBe(true) + expect( + result.stdout + .split("\n") + .slice(0, -1) + .every((line) => line.startsWith("{")), + ).toBe(true) }), 60_000, ) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index fa63156d825..9c34e336bb2 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -270,10 +270,7 @@ export function withCliFixture( } const run = (message: string, opts?: RunOpts): Effect.Effect => { - return spawn( - runArgs(message, opts), - runOpts(opts), - ) + return spawn(runArgs(message, opts), runOpts(opts)) } const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) { From 57ce1b9ca8bd9720e0a1c8397b9c545aa2412788 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 22 Jun 2026 08:05:49 -0400 Subject: [PATCH 064/112] fix(cli): increment ports from default (#33282) --- packages/cli/src/commands/handlers/serve.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 8a365a89641..d4ecfed9745 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -27,9 +27,11 @@ export default Runtime.handler( function listen(hostname: string, port: Option.Option, password: string) { if (Option.isSome(port)) return bind(hostname, port.value, password) - // Preserve the familiar default when available, but let the OS choose a free - // port when another local server already owns 4096. - return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password))) + const next = (port: number): ReturnType => + bind(hostname, port, password).pipe( + Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))), + ) + return next(4096) } function bind(hostname: string, port: number, password: string) { From 595cc91ddc95744248a772013f6418d341e10e87 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 08:12:38 -0400 Subject: [PATCH 065/112] feat(server): stream events across locations --- packages/core/src/event.ts | 18 +- .../server/routes/instance/httpapi/public.ts | 6 +- .../test/server/httpapi-exercise/index.ts | 4 +- .../test/server/httpapi-v2-location.test.ts | 25 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 14 +- packages/sdk/js/src/v2/gen/types.gen.ts | 1853 ++++++++++++++++- packages/server/src/groups/event.ts | 46 +- packages/server/src/handlers/event.ts | 19 +- packages/tui/src/context/data.tsx | 282 +-- packages/tui/test/cli/tui/data.test.tsx | 18 +- packages/tui/test/fixture/tui-sdk.ts | 29 +- 11 files changed, 2088 insertions(+), 226 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 32aaeae6995..dedafbf03a5 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -30,6 +30,15 @@ export type Definition = Schema.Schema.Type +export const Payload = Schema.Struct({ + id: ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.String, + durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), + location: Schema.optional(Location.Ref), + data: Schema.Unknown, +}) + export type Payload = { readonly id: ID readonly type: D["type"] @@ -78,16 +87,13 @@ export function define>>> & Definition> { const Data = Schema.Struct(input.schema) - const Payload = Schema.Struct({ - id: ID, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + const Event = Schema.Struct({ + ...Payload.fields, type: Schema.Literal(input.type), - durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), - location: Schema.optional(Location.Ref), data: Data, }).annotate({ identifier: input.type }) - const definition = Object.assign(Payload, { + const definition = Object.assign(Event, { type: input.type, ...(input.durable === undefined ? {} : { durable: input.durable }), data: Data, diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 8517da276fb..2a7266c5118 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -152,7 +152,7 @@ function matchLegacyOpenApi(input: Record) { normalizeLegacyErrorResponses(operation) } normalizeLegacyOperation(operation, path, method) - if ((path === "/event" || path === "/global/event") && method === "get") { + if ((path === "/event" || path === "/global/event" || path === "/api/event") && method === "get") { // HttpApi has no first-class SSE response schema, and these handlers are // raw/streaming routes. Document the actual wire protocol explicitly. operation.responses!["200"] = { @@ -162,7 +162,9 @@ function matchLegacyOpenApi(input: Record) { schema: path === "/event" ? { $ref: "#/components/schemas/Event" } - : { $ref: "#/components/schemas/GlobalEvent" }, + : path === "/global/event" + ? { $ref: "#/components/schemas/GlobalEvent" } + : { $ref: "#/components/schemas/V2Event" }, }, }, } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 5febf9cb202..728810e6efd 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -734,11 +734,11 @@ const scenarios: Scenario[] = [ .stream() .status( 200, - (ctx, result) => + (_ctx, result) => Effect.sync(() => { check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream") check(result.text.includes("server.connected"), "v2 event should emit initial connection event") - check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location") + check(!result.text.includes('"location"'), "v2 connection event should not be scoped to a location") }), "status", ), diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index 9282db32cb3..503ecb78ec8 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -21,10 +21,12 @@ function request(route: string, directory: string, init: RequestInit = {}) { const Event = Schema.Struct({ id: Schema.String, type: Schema.String, - location: Schema.Struct({ - directory: Schema.String, - project: Schema.Struct({ id: Schema.String, directory: Schema.String }), - }), + location: Schema.optional( + Schema.Struct({ + directory: Schema.String, + project: Schema.Struct({ id: Schema.String, directory: Schema.String }), + }), + ), data: Schema.Unknown, }) @@ -64,17 +66,20 @@ describe("v2 location HttpApi", () => { } }) - test("streams native EventV2 payloads with resolved locations", async () => { - await using tmp = await tmpdir({ git: true }) - const response = await request("/api/event", tmp.path) + test("streams native EventV2 payloads across locations", async () => { + await using subscriber = await tmpdir({ git: true }) + await using publisher = await tmpdir({ git: true }) + const response = await request("/api/event", subscriber.path) const reader = response.body!.getReader() - expect((await readEvent(reader)).type).toBe("server.connected") + const connected = await readEvent(reader) + expect(connected.type).toBe("server.connected") + expect(connected.location).toBeUndefined() - const created = await request("/session", tmp.path, { method: "POST" }) + const created = await request("/session", publisher.path, { method: "POST" }) expect(created.status).toBe(200) expect(await readEventType(reader, "session.created")).toMatchObject({ type: "session.created", - location: { directory: tmp.path, project: { directory: tmp.path } }, + location: { directory: publisher.path, project: { directory: publisher.path } }, data: { sessionID: expect.any(String) }, }) await reader.cancel() diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7bf19806e36..e6bec85f99d 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -6243,22 +6243,12 @@ export class Event2 extends HeyApiClient { /** * Subscribe to events * - * Subscribe to native event payloads for a location. + * Subscribe to native event payloads for the server. */ - public subscribe( - parameters?: { - location?: { - directory?: string - workspace?: string - } - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + public subscribe(options?: Options) { return (options?.client ?? this.client).sse.get({ url: "/api/event", ...options, - ...params, }) } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d2c9e298948..abb9668f666 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2764,6 +2764,94 @@ export type ProviderNotFoundError = { message: string } +export type V2Event = + | V2EventModelsDevRefreshed + | V2EventIntegrationUpdated + | V2EventCatalogUpdated + | V2EventSessionCreated + | V2EventSessionUpdated + | V2EventSessionDeleted + | V2EventMessageUpdated + | V2EventMessageRemoved + | V2EventMessagePartUpdated + | V2EventMessagePartRemoved + | V2EventSessionNextAgentSwitched + | V2EventSessionNextModelSwitched + | V2EventSessionNextMoved + | V2EventSessionNextPrompted + | V2EventSessionNextPromptAdmitted + | V2EventSessionNextPromptPromoted + | V2EventSessionNextInterruptRequested + | V2EventSessionNextContextUpdated + | V2EventSessionNextSynthetic + | V2EventSessionNextShellStarted + | V2EventSessionNextShellEnded + | V2EventSessionNextStepStarted + | V2EventSessionNextStepEnded + | V2EventSessionNextStepFailed + | V2EventSessionNextTextStarted + | V2EventSessionNextTextDelta + | V2EventSessionNextTextEnded + | V2EventSessionNextReasoningStarted + | V2EventSessionNextReasoningDelta + | V2EventSessionNextReasoningEnded + | V2EventSessionNextToolInputStarted + | V2EventSessionNextToolInputDelta + | V2EventSessionNextToolInputEnded + | V2EventSessionNextToolCalled + | V2EventSessionNextToolProgress + | V2EventSessionNextToolSuccess + | V2EventSessionNextToolFailed + | V2EventSessionNextRetried + | V2EventSessionNextCompactionStarted + | V2EventSessionNextCompactionDelta + | V2EventSessionNextCompactionEnded + | V2EventMessagePartDelta + | V2EventSessionDiff + | V2EventSessionError + | V2EventInstallationUpdated + | V2EventInstallationUpdateAvailable + | V2EventFileEdited + | V2EventPluginAdded + | V2EventPermissionV2Asked + | V2EventPermissionV2Replied + | V2EventReferenceUpdated + | V2EventProjectDirectoriesUpdated + | V2EventFileWatcherUpdated + | V2EventPtyCreated + | V2EventPtyUpdated + | V2EventPtyExited + | V2EventPtyDeleted + | V2EventQuestionV2Asked + | V2EventQuestionV2Replied + | V2EventQuestionV2Rejected + | V2EventTodoUpdated + | V2EventLspUpdated + | V2EventPermissionAsked + | V2EventPermissionReplied + | V2EventTuiPromptAppend + | V2EventTuiCommandExecute + | V2EventTuiToastShow + | V2EventTuiSessionSelect + | V2EventMcpToolsChanged + | V2EventMcpBrowserOpenFailed + | V2EventCommandExecuted + | V2EventProjectUpdated + | V2EventSessionStatus + | V2EventSessionIdle + | V2EventQuestionAsked + | V2EventQuestionReplied + | V2EventQuestionRejected + | V2EventSessionCompacted + | V2EventVcsBranchUpdated + | V2EventWorkspaceReady + | V2EventWorkspaceFailed + | V2EventWorkspaceStatus + | V2EventWorktreeReady + | V2EventWorktreeFailed + | V2EventServerConnected + | V2EventGlobalDisposed + export type ForbiddenError = { _tag: "ForbiddenError" message: string @@ -4176,6 +4264,1760 @@ export type SkillV2Info = { content: string } +export type V2EventModelsDevRefreshed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "models-dev.refreshed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type V2EventIntegrationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "integration.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type V2EventCatalogUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "catalog.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type V2EventSessionCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type V2EventSessionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type V2EventSessionDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type V2EventMessageUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Message + } +} + +export type V2EventMessageRemoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.removed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + } +} + +export type V2EventMessagePartUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + part: Part + time: number + } +} + +export type V2EventMessagePartRemoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.removed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type V2EventSessionNextAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.agent.switched" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + agent: string + } +} + +export type V2EventSessionNextModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.model.switched" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + model: { + id: string + providerID: string + variant?: string + } + } +} + +export type V2EventSessionNextMoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.moved" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } +} + +export type V2EventSessionNextPrompted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type V2EventSessionNextPromptAdmitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompt.admitted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type V2EventSessionNextPromptPromoted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompt.promoted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + timeCreated: number + } +} + +export type V2EventSessionNextInterruptRequested = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.interrupt.requested" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + } +} + +export type V2EventSessionNextContextUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.context.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type V2EventSessionNextSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.synthetic" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type V2EventSessionNextShellStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } +} + +export type V2EventSessionNextShellEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type V2EventSessionNextStepStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type V2EventSessionNextStepEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type V2EventSessionNextStepFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } +} + +export type V2EventSessionNextTextStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } +} + +export type V2EventSessionNextTextDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + delta: string + } +} + +export type V2EventSessionNextTextEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } +} + +export type V2EventSessionNextReasoningStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: { + [key: string]: { + [key: string]: unknown + } + } + } +} + +export type V2EventSessionNextReasoningDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + delta: string + } +} + +export type V2EventSessionNextReasoningEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: { + [key: string]: { + [key: string]: unknown + } + } + } +} + +export type V2EventSessionNextToolInputStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type V2EventSessionNextToolInputDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + delta: string + } +} + +export type V2EventSessionNextToolInputEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type V2EventSessionNextToolCalled = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.called" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: { + [key: string]: unknown + } + } + } + } +} + +export type V2EventSessionNextToolProgress = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.progress" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type V2EventSessionNextToolSuccess = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.success" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: { + [key: string]: { + [key: string]: unknown + } + } + } + } +} + +export type V2EventSessionNextToolFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: { + [key: string]: { + [key: string]: unknown + } + } + } + } +} + +export type V2EventSessionNextRetried = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.retried" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type V2EventSessionNextCompactionStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } +} + +export type V2EventSessionNextCompactionDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type V2EventSessionNextCompactionEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + } +} + +export type V2EventMessagePartDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type V2EventSessionDiff = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.diff" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + diff: Array + } +} + +export type V2EventSessionError = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.error" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + } +} + +export type V2EventInstallationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "installation.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + version: string + } +} + +export type V2EventInstallationUpdateAvailable = { + id: string + metadata?: { + [key: string]: unknown + } + type: "installation.update-available" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + version: string + } +} + +export type V2EventFileEdited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "file.edited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + file: string + } +} + +export type V2EventPluginAdded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "plugin.added" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + +export type V2EventPermissionV2Asked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.v2.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } +} + +export type V2EventPermissionV2Replied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.v2.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + +export type V2EventReferenceUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "reference.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type V2EventProjectDirectoriesUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "project.directories.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + projectID: string + } +} + +export type V2EventFileWatcherUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "file.watcher.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type V2EventPtyCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Pty + } +} + +export type V2EventPtyUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Pty + } +} + +export type V2EventPtyExited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.exited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + exitCode: number + } +} + +export type V2EventPtyDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + +export type V2EventQuestionV2Asked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } +} + +export type V2EventQuestionV2Replied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type V2EventQuestionV2Rejected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type V2EventTodoUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "todo.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + todos: Array + } +} + +export type V2EventLspUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "lsp.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type V2EventPermissionAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } +} + +export type V2EventPermissionReplied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type V2EventTuiPromptAppend = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.prompt.append" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + text: string + } +} + +export type V2EventTuiCommandExecute = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.command.execute" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type V2EventTuiToastShow = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.toast.show" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type V2EventTuiSessionSelect = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.session.select" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type V2EventMcpToolsChanged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.tools.changed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + server: string + } +} + +export type V2EventMcpBrowserOpenFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.browser.open.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + mcpName: string + url: string + } +} + +export type V2EventCommandExecuted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "command.executed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type V2EventProjectUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "project.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + worktree: string + vcs?: "git" + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + time: { + created: number + updated: number + initialized?: number + } + sandboxes: Array + } +} + +export type V2EventSessionStatus = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + status: SessionStatus + } +} + +export type V2EventSessionIdle = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.idle" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type V2EventQuestionAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool + } +} + +export type V2EventQuestionReplied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type V2EventQuestionRejected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type V2EventSessionCompacted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.compacted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type V2EventVcsBranchUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "vcs.branch.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + branch?: string + } +} + +export type V2EventWorkspaceReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + } +} + +export type V2EventWorkspaceFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + message: string + } +} + +export type V2EventWorkspaceStatus = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type V2EventWorktreeReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + branch?: string + } +} + +export type V2EventWorktreeFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + message: string + } +} + +export type V2EventServerConnected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "server.connected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type V2EventGlobalDisposed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "global.disposed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type QuestionV2Request = { id: string sessionID: string @@ -10687,12 +12529,7 @@ export type V2SkillListResponse = V2SkillListResponses[keyof V2SkillListResponse export type V2EventSubscribeData = { body?: never path?: never - query?: { - location?: { - directory?: string - workspace?: string - } - } + query?: never url: "/api/event" } @@ -10711,9 +12548,9 @@ export type V2EventSubscribeError = V2EventSubscribeErrors[keyof V2EventSubscrib export type V2EventSubscribeResponses = { /** - * Success + * Event stream */ - 200: string + 200: V2Event } export type V2EventSubscribeResponse = V2EventSubscribeResponses[keyof V2EventSubscribeResponses] diff --git a/packages/server/src/groups/event.ts b/packages/server/src/groups/event.ts index 83ccdf98f73..a7f91254755 100644 --- a/packages/server/src/groups/event.ts +++ b/packages/server/src/groups/event.ts @@ -1,34 +1,34 @@ import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -const Event = Schema.Struct({ - id: EventV2.ID, - type: Schema.String, - location: Location.Info.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - version: Schema.Number.pipe(Schema.optional), - data: Schema.Unknown, -}) +const Event = Schema.Union([ + ...EventV2.definitions().map((definition) => + Schema.Struct({ + ...EventV2.Payload.fields, + type: Schema.Literal(definition.type), + data: definition.data, + }).annotate({ identifier: `V2Event.${definition.type}` }), + ), + Schema.Struct({ + ...EventV2.Payload.fields, + type: Schema.Literal("server.connected"), + data: Schema.Struct({}), + }).annotate({ identifier: "V2Event.server.connected" }), +]).annotate({ identifier: "V2Event" }) export const EventGroup = HttpApiGroup.make("server.event") .add( HttpApiEndpoint.get("event.subscribe", "/api/event", { - query: LocationQuery, - success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.event.subscribe", - summary: "Subscribe to events", - description: "Subscribe to native event payloads for a location.", - }), - ), + success: Event, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.event.subscribe", + summary: "Subscribe to events", + description: "Subscribe to native event payloads for the server.", + }), + ), ) .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })) - .middleware(LocationMiddleware) export type Event = typeof Event.Type diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 65ec78a7cb3..8001fb87481 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,5 +1,4 @@ import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" import { Effect, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -20,30 +19,14 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) const events = yield* EventV2.Service return handlers.handleRaw("event.subscribe", () => Effect.gen(function* () { - const location = yield* Location.Service const connected = { id: EventV2.ID.create(), type: "server.connected", - location: new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }), data: {}, } return HttpServerResponse.stream( Stream.make(connected).pipe( - Stream.concat( - events - .all() - .pipe( - Stream.filter( - (event) => - event.location?.directory === location.directory && - event.location.workspaceID === location.workspaceID, - ), - ), - ), + Stream.concat(events.all()), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), Stream.encodeText, diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index bea6e001a43..184837c54b5 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,9 +1,7 @@ -import { useEvent } from "./event" import type { AgentV2Info, CommandV2Info, IntegrationInfo, - Event, LocationRef, ModelV2Info, PermissionSavedInfo, @@ -18,11 +16,12 @@ import type { SessionMessageAssistantTool, SessionV2Info, SkillV2Info, + V2Event, } from "@opencode-ai/sdk/v2" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -import { createSignal, onMount } from "solid-js" +import { createSignal, onCleanup, onMount } from "solid-js" type LocationData = { agent?: AgentV2Info[] @@ -71,7 +70,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ location: {}, }) - const event = useEvent() const sdk = useSDK() const [defaultLocation, setDefaultLocation] = createSignal({ directory: sdk.directory ?? process.cwd(), @@ -121,7 +119,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, } - event.subscribe((event, metadata) => { + function handleEvent(event: V2Event, metadata: { directory: string; workspace: string | undefined }) { switch (event.type) { case "catalog.updated": void Promise.all([ @@ -130,34 +128,34 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ]) break case "session.next.agent.switched": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { message.prepend(draft, { - id: event.properties.messageID, + id: event.data.messageID, type: "agent-switched", - agent: event.properties.agent, - time: { created: event.properties.timestamp }, + agent: event.data.agent, + time: { created: event.data.timestamp }, }) }) break case "session.next.model.switched": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { message.prepend(draft, { - id: event.properties.messageID, + id: event.data.messageID, type: "model-switched", - model: event.properties.model, - time: { created: event.properties.timestamp }, + model: event.data.model, + time: { created: event.data.timestamp }, }) }) break case "session.next.prompted": { - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { message.prepend(draft, { - id: event.properties.messageID, + id: event.data.messageID, type: "user", - text: event.properties.prompt.text, - files: event.properties.prompt.files, - agents: event.properties.prompt.agents, - time: { created: event.properties.timestamp }, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timestamp }, }) }) break @@ -165,248 +163,248 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.next.prompt.admitted": break case "session.next.prompt.promoted": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { message.prepend(draft, { - id: event.properties.messageID, + id: event.data.messageID, type: "user", - text: event.properties.prompt.text, - files: event.properties.prompt.files, - agents: event.properties.prompt.agents, - time: { created: event.properties.timeCreated }, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timeCreated }, }) }) break case "session.next.context.updated": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { message.prepend(draft, { - id: event.properties.messageID, + id: event.data.messageID, type: "system", - text: event.properties.text, - time: { created: event.properties.timestamp }, + text: event.data.text, + time: { created: event.data.timestamp }, }) }) break case "session.next.synthetic": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { message.prepend(draft, { - id: event.properties.messageID, + id: event.data.messageID, type: "synthetic", - sessionID: event.properties.sessionID, - text: event.properties.text, - time: { created: event.properties.timestamp }, + sessionID: event.data.sessionID, + text: event.data.text, + time: { created: event.data.timestamp }, }) }) break case "session.next.shell.started": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { message.prepend(draft, { - id: event.properties.messageID, + id: event.data.messageID, type: "shell", - callID: event.properties.callID, - command: event.properties.command, + callID: event.data.callID, + command: event.data.command, output: "", - time: { created: event.properties.timestamp }, + time: { created: event.data.timestamp }, }) }) break case "session.next.shell.ended": - message.update(event.properties.sessionID, (draft) => { - const match = message.activeShell(draft, event.properties.callID) + message.update(event.data.sessionID, (draft) => { + const match = message.activeShell(draft, event.data.callID) if (!match) return - match.output = event.properties.output - match.time.completed = event.properties.timestamp + match.output = event.data.output + match.time.completed = event.data.timestamp }) break case "session.next.step.started": - message.update(event.properties.sessionID, (draft) => { - if (draft.some((message) => message.id === event.properties.assistantMessageID)) return + message.update(event.data.sessionID, (draft) => { + if (draft.some((message) => message.id === event.data.assistantMessageID)) return const currentAssistant = message.activeAssistant(draft) - if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp + if (currentAssistant) currentAssistant.time.completed = event.data.timestamp message.prepend(draft, { - id: event.properties.assistantMessageID, + id: event.data.assistantMessageID, type: "assistant", - agent: event.properties.agent, - model: event.properties.model, + agent: event.data.agent, + model: event.data.model, content: [], - snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, - time: { created: event.properties.timestamp }, + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + time: { created: event.data.timestamp }, }) }) break case "session.next.step.ended": - message.update(event.properties.sessionID, (draft) => { - const currentAssistant = message.assistant(draft, event.properties.assistantMessageID) + message.update(event.data.sessionID, (draft) => { + const currentAssistant = message.assistant(draft, event.data.assistantMessageID) if (!currentAssistant) return - currentAssistant.time.completed = event.properties.timestamp - currentAssistant.finish = event.properties.finish - currentAssistant.cost = event.properties.cost - currentAssistant.tokens = event.properties.tokens - if (event.properties.snapshot) - currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } + currentAssistant.time.completed = event.data.timestamp + currentAssistant.finish = event.data.finish + currentAssistant.cost = event.data.cost + currentAssistant.tokens = event.data.tokens + if (event.data.snapshot) + currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } }) break case "session.next.step.failed": - message.update(event.properties.sessionID, (draft) => { - const currentAssistant = message.assistant(draft, event.properties.assistantMessageID) + message.update(event.data.sessionID, (draft) => { + const currentAssistant = message.assistant(draft, event.data.assistantMessageID) if (!currentAssistant) return - currentAssistant.time.completed = event.properties.timestamp + currentAssistant.time.completed = event.data.timestamp currentAssistant.finish = "error" - currentAssistant.error = event.properties.error + currentAssistant.error = event.data.error }) break case "session.next.text.started": - message.update(event.properties.sessionID, (draft) => { - message.assistant(draft, event.properties.assistantMessageID)?.content.push({ + message.update(event.data.sessionID, (draft) => { + message.assistant(draft, event.data.assistantMessageID)?.content.push({ type: "text", - id: event.properties.textID, + id: event.data.textID, text: "", }) }) break case "session.next.text.delta": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestText( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.textID, + message.assistant(draft, event.data.assistantMessageID), + event.data.textID, ) - if (match) match.text += event.properties.delta + if (match) match.text += event.data.delta }) break case "session.next.text.ended": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestText( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.textID, + message.assistant(draft, event.data.assistantMessageID), + event.data.textID, ) - if (match) match.text = event.properties.text + if (match) match.text = event.data.text }) break case "session.next.tool.input.started": - message.update(event.properties.sessionID, (draft) => { - message.assistant(draft, event.properties.assistantMessageID)?.content.push({ + message.update(event.data.sessionID, (draft) => { + message.assistant(draft, event.data.assistantMessageID)?.content.push({ type: "tool", - id: event.properties.callID, - name: event.properties.name, - time: { created: event.properties.timestamp }, + id: event.data.callID, + name: event.data.name, + time: { created: event.data.timestamp }, state: { status: "pending", input: "" }, }) }) break case "session.next.tool.input.delta": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestTool( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.callID, + message.assistant(draft, event.data.assistantMessageID), + event.data.callID, ) - if (match?.state.status === "pending") match.state.input += event.properties.delta + if (match?.state.status === "pending") match.state.input += event.data.delta }) break case "session.next.tool.input.ended": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestTool( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.callID, + message.assistant(draft, event.data.assistantMessageID), + event.data.callID, ) - if (match?.state.status === "pending") match.state.input = event.properties.text + if (match?.state.status === "pending") match.state.input = event.data.text }) break case "session.next.tool.called": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestTool( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.callID, + message.assistant(draft, event.data.assistantMessageID), + event.data.callID, ) if (!match) return - match.time.ran = event.properties.timestamp - match.provider = event.properties.provider - match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } + match.time.ran = event.data.timestamp + match.provider = event.data.provider + match.state = { status: "running", input: event.data.input, structured: {}, content: [] } }) break case "session.next.tool.progress": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestTool( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.callID, + message.assistant(draft, event.data.assistantMessageID), + event.data.callID, ) if (match?.state.status !== "running") return - match.state.structured = event.properties.structured - match.state.content = [...event.properties.content] + match.state.structured = event.data.structured + match.state.content = [...event.data.content] }) break case "session.next.tool.success": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestTool( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.callID, + message.assistant(draft, event.data.assistantMessageID), + event.data.callID, ) if (match?.state.status !== "running") return match.state = { status: "completed", input: match.state.input, - structured: event.properties.structured, - content: [...event.properties.content], - result: event.properties.result, + structured: event.data.structured, + content: [...event.data.content], + result: event.data.result, } match.provider = { - executed: event.properties.provider.executed || match.provider?.executed === true, + executed: event.data.provider.executed || match.provider?.executed === true, metadata: match.provider?.metadata, - resultMetadata: event.properties.provider.metadata, + resultMetadata: event.data.provider.metadata, } - match.time.completed = event.properties.timestamp + match.time.completed = event.data.timestamp }) break case "session.next.tool.failed": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestTool( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.callID, + message.assistant(draft, event.data.assistantMessageID), + event.data.callID, ) if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return match.state = { status: "error", - error: event.properties.error, + error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, structured: match.state.status === "running" ? match.state.structured : {}, content: match.state.status === "running" ? match.state.content : [], - result: event.properties.result, + result: event.data.result, } match.provider = { - executed: event.properties.provider.executed || match.provider?.executed === true, + executed: event.data.provider.executed || match.provider?.executed === true, metadata: match.provider?.metadata, - resultMetadata: event.properties.provider.metadata, + resultMetadata: event.data.provider.metadata, } - match.time.completed = event.properties.timestamp + match.time.completed = event.data.timestamp }) break case "session.next.reasoning.started": - message.update(event.properties.sessionID, (draft) => { - message.assistant(draft, event.properties.assistantMessageID)?.content.push({ + message.update(event.data.sessionID, (draft) => { + message.assistant(draft, event.data.assistantMessageID)?.content.push({ type: "reasoning", - id: event.properties.reasoningID, + id: event.data.reasoningID, text: "", - providerMetadata: event.properties.providerMetadata, + providerMetadata: event.data.providerMetadata, }) }) break case "session.next.reasoning.delta": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestReasoning( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.reasoningID, + message.assistant(draft, event.data.assistantMessageID), + event.data.reasoningID, ) - if (match) match.text += event.properties.delta + if (match) match.text += event.data.delta }) break case "session.next.reasoning.ended": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { const match = message.latestReasoning( - message.assistant(draft, event.properties.assistantMessageID), - event.properties.reasoningID, + message.assistant(draft, event.data.assistantMessageID), + event.data.reasoningID, ) if (match) { - match.text = event.properties.text - if (event.properties.providerMetadata !== undefined) - match.providerMetadata = event.properties.providerMetadata + match.text = event.data.text + if (event.data.providerMetadata !== undefined) + match.providerMetadata = event.data.providerMetadata } }) break @@ -415,14 +413,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.next.compaction.delta": break case "session.next.compaction.ended": - message.update(event.properties.sessionID, (draft) => { + message.update(event.data.sessionID, (draft) => { message.prepend(draft, { - id: event.properties.messageID, + id: event.data.messageID, type: "compaction", - reason: event.properties.reason, - summary: event.properties.text, - recent: event.properties.recent, - time: { created: event.properties.timestamp }, + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + time: { created: event.data.timestamp }, }) }) break @@ -437,6 +435,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ]) break } + } + + onMount(() => { + const controller = new AbortController() + onCleanup(() => controller.abort()) + void (async () => { + const events = await sdk.client.v2.event.subscribe({ signal: controller.signal }) + for await (const event of events.stream) { + handleEvent(event, { + directory: event.location?.directory ?? defaultLocation().directory, + workspace: event.location?.workspaceID, + }) + } + })().catch(() => {}) }) const result = { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 92894620acc..0d6ada4d161 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -26,6 +26,7 @@ function emitEvent(events: ReturnType, payload: Event) } test("refreshes resources into reactive getters", async () => { + const events = createEventSource() const location = { directory, project: { id: "proj_test", directory }, @@ -49,8 +50,7 @@ test("refreshes resources into reactive getters", async () => { data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }], }) return undefined - }) - const events = createEventSource() + }, events) let data!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { @@ -119,7 +119,7 @@ test("refreshes integrations after integration updates", async () => { }, ], }) - }) + }, events) let data!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { @@ -171,7 +171,7 @@ test("refreshes effective catalog data after catalog updates", async () => { requests.provider++ return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] }) } - }) + }, events) const app = await testRender(() => ( @@ -205,7 +205,7 @@ test("refreshes references after updates", async () => { location: { directory, project: { id: "proj_test", directory } }, data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }], }) - }) + }, events) let data!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { @@ -243,7 +243,7 @@ test("refreshes references after updates", async () => { test("settles pending tools when a live failure arrives", async () => { const events = createEventSource() - const calls = createFetch() + const calls = createFetch(undefined, events) let sync!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { @@ -372,7 +372,7 @@ test("settles pending tools when a live failure arrives", async () => { test("renders admitted prompts only after promotion", async () => { const events = createEventSource() - const calls = createFetch() + const calls = createFetch(undefined, events) let sync!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { @@ -436,7 +436,7 @@ test("renders admitted prompts only after promotion", async () => { test("renders a promoted prompt when admission was missed", async () => { const events = createEventSource() - const calls = createFetch() + const calls = createFetch(undefined, events) let sync!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { @@ -484,7 +484,7 @@ test("renders a promoted prompt when admission was missed", async () => { test("projects live context updates with their message ID", async () => { const events = createEventSource() - const calls = createFetch() + const calls = createFetch(undefined, events) let sync!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index ed18b7acde5..d1cf3c7dfc2 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -17,6 +17,8 @@ export function eventSource(): EventSource { export function createEventSource() { let fn: ((event: GlobalEvent) => void) | undefined + let stream: ReadableStreamDefaultController | undefined + const pending: Uint8Array[] = [] return { source: { subscribe: async (handler: (event: GlobalEvent) => void) => { @@ -29,19 +31,44 @@ export function createEventSource() { emit(event: GlobalEvent) { if (!fn) throw new Error("event source not ready") fn(event) + if (!("properties" in event.payload)) return + const chunk = new TextEncoder().encode( + `data: ${JSON.stringify({ + ...event.payload, + location: { directory: event.directory, workspaceID: event.workspace }, + data: event.payload.properties, + })}\n\n`, + ) + if (stream) return stream.enqueue(chunk) + pending.push(chunk) + }, + response() { + return new Response( + new ReadableStream({ + start(controller) { + stream = controller + for (const chunk of pending.splice(0)) controller.enqueue(chunk) + }, + cancel() { + stream = undefined + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) }, } } export type FetchHandler = (url: URL) => Response | Promise | undefined -export function createFetch(override?: FetchHandler) { +export function createFetch(override?: FetchHandler, events?: ReturnType) { const session = [] as URL[] const fetch = (async (input: RequestInfo | URL) => { const url = new URL(input instanceof Request ? input.url : String(input)) if (url.pathname === "/session") session.push(url) const overridden = await override?.(url) if (overridden) return overridden + if (url.pathname === "/api/event" && events) return events.response() if ( [ From 1b91b5df34a93104975c7faa06b7d28d9f782692 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 08:14:49 -0400 Subject: [PATCH 066/112] fix(core): constrain event schema services --- packages/core/src/event.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index dedafbf03a5..47f9753b948 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -19,6 +19,11 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( ) export type ID = typeof ID.Type +type ServiceFreeSchema = Schema.Top & { + readonly DecodingServices: never + readonly EncodingServices: never +} + export type Definition = { readonly type: Type readonly durable?: { @@ -75,10 +80,10 @@ export function versionedType(type: string, version: number) { return `${type}.${version}` } -export const registry = new Map() -const durableRegistry = new Map() +export const registry = new Map>() +const durableRegistry = new Map>() -export function define(input: { +export function define>(input: { readonly type: Type readonly durable?: { readonly version: number From b13a2d712a40721ef1799ef03431e9468246e559 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 08:20:39 -0400 Subject: [PATCH 067/112] fix(server): isolate event schema types --- packages/core/src/event.ts | 29 +++++++++-------------------- packages/server/src/groups/event.ts | 14 +++++++++++--- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 47f9753b948..32aaeae6995 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -19,11 +19,6 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( ) export type ID = typeof ID.Type -type ServiceFreeSchema = Schema.Top & { - readonly DecodingServices: never - readonly EncodingServices: never -} - export type Definition = { readonly type: Type readonly durable?: { @@ -35,15 +30,6 @@ export type Definition = Schema.Schema.Type -export const Payload = Schema.Struct({ - id: ID, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - type: Schema.String, - durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), - location: Schema.optional(Location.Ref), - data: Schema.Unknown, -}) - export type Payload = { readonly id: ID readonly type: D["type"] @@ -80,10 +66,10 @@ export function versionedType(type: string, version: number) { return `${type}.${version}` } -export const registry = new Map>() -const durableRegistry = new Map>() +export const registry = new Map() +const durableRegistry = new Map() -export function define>(input: { +export function define(input: { readonly type: Type readonly durable?: { readonly version: number @@ -92,13 +78,16 @@ export function define>>> & Definition> { const Data = Schema.Struct(input.schema) - const Event = Schema.Struct({ - ...Payload.fields, + const Payload = Schema.Struct({ + id: ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), + durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), + location: Schema.optional(Location.Ref), data: Data, }).annotate({ identifier: input.type }) - const definition = Object.assign(Event, { + const definition = Object.assign(Payload, { type: input.type, ...(input.durable === undefined ? {} : { durable: input.durable }), data: Data, diff --git a/packages/server/src/groups/event.ts b/packages/server/src/groups/event.ts index a7f91254755..fffa0250d2e 100644 --- a/packages/server/src/groups/event.ts +++ b/packages/server/src/groups/event.ts @@ -1,17 +1,25 @@ import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +const fields = { + id: EventV2.ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), + location: Schema.optional(Location.Ref), +} + const Event = Schema.Union([ ...EventV2.definitions().map((definition) => Schema.Struct({ - ...EventV2.Payload.fields, + ...fields, type: Schema.Literal(definition.type), - data: definition.data, + data: definition.data as Schema.Struct<{}>, }).annotate({ identifier: `V2Event.${definition.type}` }), ), Schema.Struct({ - ...EventV2.Payload.fields, + ...fields, type: Schema.Literal("server.connected"), data: Schema.Struct({}), }).annotate({ identifier: "V2Event.server.connected" }), From 639c94a37543d610f11adc2816293b000b557d12 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 22 Jun 2026 12:22:43 +0000 Subject: [PATCH 068/112] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 172 +- packages/sdk/openapi.json | 5213 ++++++++++++++++++++++- packages/tui/src/context/data.tsx | 43 +- 3 files changed, 5284 insertions(+), 144 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index abb9668f666..b2900e8d618 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4269,13 +4269,13 @@ export type V2EventModelsDevRefreshed = { metadata?: { [key: string]: unknown } - type: "models-dev.refreshed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "models-dev.refreshed" data: { [key: string]: unknown } @@ -4286,13 +4286,13 @@ export type V2EventIntegrationUpdated = { metadata?: { [key: string]: unknown } - type: "integration.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "integration.updated" data: { [key: string]: unknown } @@ -4303,13 +4303,13 @@ export type V2EventCatalogUpdated = { metadata?: { [key: string]: unknown } - type: "catalog.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "catalog.updated" data: { [key: string]: unknown } @@ -4320,13 +4320,13 @@ export type V2EventSessionCreated = { metadata?: { [key: string]: unknown } - type: "session.created" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.created" data: { sessionID: string info: Session @@ -4338,13 +4338,13 @@ export type V2EventSessionUpdated = { metadata?: { [key: string]: unknown } - type: "session.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.updated" data: { sessionID: string info: Session @@ -4356,13 +4356,13 @@ export type V2EventSessionDeleted = { metadata?: { [key: string]: unknown } - type: "session.deleted" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.deleted" data: { sessionID: string info: Session @@ -4374,13 +4374,13 @@ export type V2EventMessageUpdated = { metadata?: { [key: string]: unknown } - type: "message.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "message.updated" data: { sessionID: string info: Message @@ -4392,13 +4392,13 @@ export type V2EventMessageRemoved = { metadata?: { [key: string]: unknown } - type: "message.removed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "message.removed" data: { sessionID: string messageID: string @@ -4410,13 +4410,13 @@ export type V2EventMessagePartUpdated = { metadata?: { [key: string]: unknown } - type: "message.part.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "message.part.updated" data: { sessionID: string part: Part @@ -4429,13 +4429,13 @@ export type V2EventMessagePartRemoved = { metadata?: { [key: string]: unknown } - type: "message.part.removed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "message.part.removed" data: { sessionID: string messageID: string @@ -4448,13 +4448,13 @@ export type V2EventSessionNextAgentSwitched = { metadata?: { [key: string]: unknown } - type: "session.next.agent.switched" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.agent.switched" data: { timestamp: number sessionID: string @@ -4468,13 +4468,13 @@ export type V2EventSessionNextModelSwitched = { metadata?: { [key: string]: unknown } - type: "session.next.model.switched" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.model.switched" data: { timestamp: number sessionID: string @@ -4492,13 +4492,13 @@ export type V2EventSessionNextMoved = { metadata?: { [key: string]: unknown } - type: "session.next.moved" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.moved" data: { timestamp: number sessionID: string @@ -4512,13 +4512,13 @@ export type V2EventSessionNextPrompted = { metadata?: { [key: string]: unknown } - type: "session.next.prompted" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.prompted" data: { timestamp: number sessionID: string @@ -4533,13 +4533,13 @@ export type V2EventSessionNextPromptAdmitted = { metadata?: { [key: string]: unknown } - type: "session.next.prompt.admitted" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.prompt.admitted" data: { timestamp: number sessionID: string @@ -4554,13 +4554,13 @@ export type V2EventSessionNextPromptPromoted = { metadata?: { [key: string]: unknown } - type: "session.next.prompt.promoted" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.prompt.promoted" data: { timestamp: number sessionID: string @@ -4575,13 +4575,13 @@ export type V2EventSessionNextInterruptRequested = { metadata?: { [key: string]: unknown } - type: "session.next.interrupt.requested" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.interrupt.requested" data: { timestamp: number sessionID: string @@ -4593,13 +4593,13 @@ export type V2EventSessionNextContextUpdated = { metadata?: { [key: string]: unknown } - type: "session.next.context.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.context.updated" data: { timestamp: number sessionID: string @@ -4613,13 +4613,13 @@ export type V2EventSessionNextSynthetic = { metadata?: { [key: string]: unknown } - type: "session.next.synthetic" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.synthetic" data: { timestamp: number sessionID: string @@ -4633,13 +4633,13 @@ export type V2EventSessionNextShellStarted = { metadata?: { [key: string]: unknown } - type: "session.next.shell.started" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.shell.started" data: { timestamp: number sessionID: string @@ -4654,13 +4654,13 @@ export type V2EventSessionNextShellEnded = { metadata?: { [key: string]: unknown } - type: "session.next.shell.ended" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.shell.ended" data: { timestamp: number sessionID: string @@ -4674,13 +4674,13 @@ export type V2EventSessionNextStepStarted = { metadata?: { [key: string]: unknown } - type: "session.next.step.started" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.step.started" data: { timestamp: number sessionID: string @@ -4700,13 +4700,13 @@ export type V2EventSessionNextStepEnded = { metadata?: { [key: string]: unknown } - type: "session.next.step.ended" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.step.ended" data: { timestamp: number sessionID: string @@ -4731,13 +4731,13 @@ export type V2EventSessionNextStepFailed = { metadata?: { [key: string]: unknown } - type: "session.next.step.failed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.step.failed" data: { timestamp: number sessionID: string @@ -4751,13 +4751,13 @@ export type V2EventSessionNextTextStarted = { metadata?: { [key: string]: unknown } - type: "session.next.text.started" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.text.started" data: { timestamp: number sessionID: string @@ -4771,13 +4771,13 @@ export type V2EventSessionNextTextDelta = { metadata?: { [key: string]: unknown } - type: "session.next.text.delta" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.text.delta" data: { timestamp: number sessionID: string @@ -4792,13 +4792,13 @@ export type V2EventSessionNextTextEnded = { metadata?: { [key: string]: unknown } - type: "session.next.text.ended" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.text.ended" data: { timestamp: number sessionID: string @@ -4813,13 +4813,13 @@ export type V2EventSessionNextReasoningStarted = { metadata?: { [key: string]: unknown } - type: "session.next.reasoning.started" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.reasoning.started" data: { timestamp: number sessionID: string @@ -4838,13 +4838,13 @@ export type V2EventSessionNextReasoningDelta = { metadata?: { [key: string]: unknown } - type: "session.next.reasoning.delta" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.reasoning.delta" data: { timestamp: number sessionID: string @@ -4859,13 +4859,13 @@ export type V2EventSessionNextReasoningEnded = { metadata?: { [key: string]: unknown } - type: "session.next.reasoning.ended" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.reasoning.ended" data: { timestamp: number sessionID: string @@ -4885,13 +4885,13 @@ export type V2EventSessionNextToolInputStarted = { metadata?: { [key: string]: unknown } - type: "session.next.tool.input.started" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.tool.input.started" data: { timestamp: number sessionID: string @@ -4906,13 +4906,13 @@ export type V2EventSessionNextToolInputDelta = { metadata?: { [key: string]: unknown } - type: "session.next.tool.input.delta" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.tool.input.delta" data: { timestamp: number sessionID: string @@ -4927,13 +4927,13 @@ export type V2EventSessionNextToolInputEnded = { metadata?: { [key: string]: unknown } - type: "session.next.tool.input.ended" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.tool.input.ended" data: { timestamp: number sessionID: string @@ -4948,13 +4948,13 @@ export type V2EventSessionNextToolCalled = { metadata?: { [key: string]: unknown } - type: "session.next.tool.called" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.tool.called" data: { timestamp: number sessionID: string @@ -4980,13 +4980,13 @@ export type V2EventSessionNextToolProgress = { metadata?: { [key: string]: unknown } - type: "session.next.tool.progress" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.tool.progress" data: { timestamp: number sessionID: string @@ -5004,13 +5004,13 @@ export type V2EventSessionNextToolSuccess = { metadata?: { [key: string]: unknown } - type: "session.next.tool.success" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.tool.success" data: { timestamp: number sessionID: string @@ -5038,13 +5038,13 @@ export type V2EventSessionNextToolFailed = { metadata?: { [key: string]: unknown } - type: "session.next.tool.failed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.tool.failed" data: { timestamp: number sessionID: string @@ -5068,13 +5068,13 @@ export type V2EventSessionNextRetried = { metadata?: { [key: string]: unknown } - type: "session.next.retried" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.retried" data: { timestamp: number sessionID: string @@ -5088,13 +5088,13 @@ export type V2EventSessionNextCompactionStarted = { metadata?: { [key: string]: unknown } - type: "session.next.compaction.started" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.compaction.started" data: { timestamp: number sessionID: string @@ -5108,13 +5108,13 @@ export type V2EventSessionNextCompactionDelta = { metadata?: { [key: string]: unknown } - type: "session.next.compaction.delta" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.compaction.delta" data: { timestamp: number sessionID: string @@ -5128,13 +5128,13 @@ export type V2EventSessionNextCompactionEnded = { metadata?: { [key: string]: unknown } - type: "session.next.compaction.ended" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.next.compaction.ended" data: { timestamp: number sessionID: string @@ -5150,13 +5150,13 @@ export type V2EventMessagePartDelta = { metadata?: { [key: string]: unknown } - type: "message.part.delta" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "message.part.delta" data: { sessionID: string messageID: string @@ -5171,13 +5171,13 @@ export type V2EventSessionDiff = { metadata?: { [key: string]: unknown } - type: "session.diff" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.diff" data: { sessionID: string diff: Array @@ -5189,13 +5189,13 @@ export type V2EventSessionError = { metadata?: { [key: string]: unknown } - type: "session.error" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.error" data: { sessionID?: string error?: @@ -5215,13 +5215,13 @@ export type V2EventInstallationUpdated = { metadata?: { [key: string]: unknown } - type: "installation.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "installation.updated" data: { version: string } @@ -5232,13 +5232,13 @@ export type V2EventInstallationUpdateAvailable = { metadata?: { [key: string]: unknown } - type: "installation.update-available" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "installation.update-available" data: { version: string } @@ -5249,13 +5249,13 @@ export type V2EventFileEdited = { metadata?: { [key: string]: unknown } - type: "file.edited" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "file.edited" data: { file: string } @@ -5266,13 +5266,13 @@ export type V2EventPluginAdded = { metadata?: { [key: string]: unknown } - type: "plugin.added" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "plugin.added" data: { id: string } @@ -5283,13 +5283,13 @@ export type V2EventPermissionV2Asked = { metadata?: { [key: string]: unknown } - type: "permission.v2.asked" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "permission.v2.asked" data: { id: string sessionID: string @@ -5308,13 +5308,13 @@ export type V2EventPermissionV2Replied = { metadata?: { [key: string]: unknown } - type: "permission.v2.replied" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "permission.v2.replied" data: { sessionID: string requestID: string @@ -5327,13 +5327,13 @@ export type V2EventReferenceUpdated = { metadata?: { [key: string]: unknown } - type: "reference.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "reference.updated" data: { [key: string]: unknown } @@ -5344,13 +5344,13 @@ export type V2EventProjectDirectoriesUpdated = { metadata?: { [key: string]: unknown } - type: "project.directories.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "project.directories.updated" data: { projectID: string } @@ -5361,13 +5361,13 @@ export type V2EventFileWatcherUpdated = { metadata?: { [key: string]: unknown } - type: "file.watcher.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "file.watcher.updated" data: { file: string event: "add" | "change" | "unlink" @@ -5379,13 +5379,13 @@ export type V2EventPtyCreated = { metadata?: { [key: string]: unknown } - type: "pty.created" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "pty.created" data: { info: Pty } @@ -5396,13 +5396,13 @@ export type V2EventPtyUpdated = { metadata?: { [key: string]: unknown } - type: "pty.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "pty.updated" data: { info: Pty } @@ -5413,13 +5413,13 @@ export type V2EventPtyExited = { metadata?: { [key: string]: unknown } - type: "pty.exited" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "pty.exited" data: { id: string exitCode: number @@ -5431,13 +5431,13 @@ export type V2EventPtyDeleted = { metadata?: { [key: string]: unknown } - type: "pty.deleted" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "pty.deleted" data: { id: string } @@ -5448,13 +5448,13 @@ export type V2EventQuestionV2Asked = { metadata?: { [key: string]: unknown } - type: "question.v2.asked" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "question.v2.asked" data: { id: string sessionID: string @@ -5471,13 +5471,13 @@ export type V2EventQuestionV2Replied = { metadata?: { [key: string]: unknown } - type: "question.v2.replied" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "question.v2.replied" data: { sessionID: string requestID: string @@ -5490,13 +5490,13 @@ export type V2EventQuestionV2Rejected = { metadata?: { [key: string]: unknown } - type: "question.v2.rejected" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "question.v2.rejected" data: { sessionID: string requestID: string @@ -5508,13 +5508,13 @@ export type V2EventTodoUpdated = { metadata?: { [key: string]: unknown } - type: "todo.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "todo.updated" data: { sessionID: string todos: Array @@ -5526,13 +5526,13 @@ export type V2EventLspUpdated = { metadata?: { [key: string]: unknown } - type: "lsp.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "lsp.updated" data: { [key: string]: unknown } @@ -5543,13 +5543,13 @@ export type V2EventPermissionAsked = { metadata?: { [key: string]: unknown } - type: "permission.asked" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "permission.asked" data: { id: string sessionID: string @@ -5571,13 +5571,13 @@ export type V2EventPermissionReplied = { metadata?: { [key: string]: unknown } - type: "permission.replied" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "permission.replied" data: { sessionID: string requestID: string @@ -5590,13 +5590,13 @@ export type V2EventTuiPromptAppend = { metadata?: { [key: string]: unknown } - type: "tui.prompt.append" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "tui.prompt.append" data: { text: string } @@ -5607,13 +5607,13 @@ export type V2EventTuiCommandExecute = { metadata?: { [key: string]: unknown } - type: "tui.command.execute" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "tui.command.execute" data: { command: | "session.list" @@ -5641,13 +5641,13 @@ export type V2EventTuiToastShow = { metadata?: { [key: string]: unknown } - type: "tui.toast.show" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "tui.toast.show" data: { title?: string message: string @@ -5661,13 +5661,13 @@ export type V2EventTuiSessionSelect = { metadata?: { [key: string]: unknown } - type: "tui.session.select" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "tui.session.select" data: { /** * Session ID to navigate to @@ -5681,13 +5681,13 @@ export type V2EventMcpToolsChanged = { metadata?: { [key: string]: unknown } - type: "mcp.tools.changed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "mcp.tools.changed" data: { server: string } @@ -5698,13 +5698,13 @@ export type V2EventMcpBrowserOpenFailed = { metadata?: { [key: string]: unknown } - type: "mcp.browser.open.failed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "mcp.browser.open.failed" data: { mcpName: string url: string @@ -5716,13 +5716,13 @@ export type V2EventCommandExecuted = { metadata?: { [key: string]: unknown } - type: "command.executed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "command.executed" data: { name: string sessionID: string @@ -5736,13 +5736,13 @@ export type V2EventProjectUpdated = { metadata?: { [key: string]: unknown } - type: "project.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "project.updated" data: { id: string worktree: string @@ -5773,13 +5773,13 @@ export type V2EventSessionStatus = { metadata?: { [key: string]: unknown } - type: "session.status" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.status" data: { sessionID: string status: SessionStatus @@ -5791,13 +5791,13 @@ export type V2EventSessionIdle = { metadata?: { [key: string]: unknown } - type: "session.idle" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.idle" data: { sessionID: string } @@ -5808,13 +5808,13 @@ export type V2EventQuestionAsked = { metadata?: { [key: string]: unknown } - type: "question.asked" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "question.asked" data: { id: string sessionID: string @@ -5831,13 +5831,13 @@ export type V2EventQuestionReplied = { metadata?: { [key: string]: unknown } - type: "question.replied" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "question.replied" data: { sessionID: string requestID: string @@ -5850,13 +5850,13 @@ export type V2EventQuestionRejected = { metadata?: { [key: string]: unknown } - type: "question.rejected" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "question.rejected" data: { sessionID: string requestID: string @@ -5868,13 +5868,13 @@ export type V2EventSessionCompacted = { metadata?: { [key: string]: unknown } - type: "session.compacted" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "session.compacted" data: { sessionID: string } @@ -5885,13 +5885,13 @@ export type V2EventVcsBranchUpdated = { metadata?: { [key: string]: unknown } - type: "vcs.branch.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "vcs.branch.updated" data: { branch?: string } @@ -5902,13 +5902,13 @@ export type V2EventWorkspaceReady = { metadata?: { [key: string]: unknown } - type: "workspace.ready" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "workspace.ready" data: { name: string } @@ -5919,13 +5919,13 @@ export type V2EventWorkspaceFailed = { metadata?: { [key: string]: unknown } - type: "workspace.failed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "workspace.failed" data: { message: string } @@ -5936,13 +5936,13 @@ export type V2EventWorkspaceStatus = { metadata?: { [key: string]: unknown } - type: "workspace.status" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "workspace.status" data: { workspaceID: string status: "connected" | "connecting" | "disconnected" | "error" @@ -5954,13 +5954,13 @@ export type V2EventWorktreeReady = { metadata?: { [key: string]: unknown } - type: "worktree.ready" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "worktree.ready" data: { name: string branch?: string @@ -5972,13 +5972,13 @@ export type V2EventWorktreeFailed = { metadata?: { [key: string]: unknown } - type: "worktree.failed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "worktree.failed" data: { message: string } @@ -5989,13 +5989,13 @@ export type V2EventServerConnected = { metadata?: { [key: string]: unknown } - type: "server.connected" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "server.connected" data: { [key: string]: unknown } @@ -6006,13 +6006,13 @@ export type V2EventGlobalDisposed = { metadata?: { [key: string]: unknown } - type: "global.disposed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef + type: "global.disposed" data: { [key: string]: unknown } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 04165386c72..d9aee60510d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -13138,35 +13138,15 @@ "get": { "tags": ["events"], "operationId": "v2.event.subscribe", - "parameters": [ - { - "name": "location", - "in": "query", - "schema": { - "type": "object", - "properties": { - "directory": { - "type": "string" - }, - "workspace": { - "type": "string" - } - }, - "additionalProperties": false - }, - "required": false, - "style": "deepObject", - "explode": true - } - ], + "parameters": [], "security": [], "responses": { "200": { - "description": "Success", + "description": "Event stream", "content": { "text/event-stream": { "schema": { - "type": "string" + "$ref": "#/components/schemas/V2Event" } } } @@ -13192,7 +13172,7 @@ } } }, - "description": "Subscribe to native event payloads for a location.", + "description": "Subscribe to native event payloads for the server.", "summary": "Subscribe to events", "x-codeSamples": [ { @@ -23081,6 +23061,271 @@ "required": ["_tag", "providerID", "message"], "additionalProperties": false }, + "V2Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2EventModels-devRefreshed" + }, + { + "$ref": "#/components/schemas/V2EventIntegrationUpdated" + }, + { + "$ref": "#/components/schemas/V2EventCatalogUpdated" + }, + { + "$ref": "#/components/schemas/V2EventSessionCreated" + }, + { + "$ref": "#/components/schemas/V2EventSessionUpdated" + }, + { + "$ref": "#/components/schemas/V2EventSessionDeleted" + }, + { + "$ref": "#/components/schemas/V2EventMessageUpdated" + }, + { + "$ref": "#/components/schemas/V2EventMessageRemoved" + }, + { + "$ref": "#/components/schemas/V2EventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/V2EventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextMoved" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextPromptPromoted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextInterruptRequested" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextContextUpdated" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/V2EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/V2EventMessagePartDelta" + }, + { + "$ref": "#/components/schemas/V2EventSessionDiff" + }, + { + "$ref": "#/components/schemas/V2EventSessionError" + }, + { + "$ref": "#/components/schemas/V2EventInstallationUpdated" + }, + { + "$ref": "#/components/schemas/V2EventInstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/V2EventFileEdited" + }, + { + "$ref": "#/components/schemas/V2EventPluginAdded" + }, + { + "$ref": "#/components/schemas/V2EventPermissionV2Asked" + }, + { + "$ref": "#/components/schemas/V2EventPermissionV2Replied" + }, + { + "$ref": "#/components/schemas/V2EventReferenceUpdated" + }, + { + "$ref": "#/components/schemas/V2EventProjectDirectoriesUpdated" + }, + { + "$ref": "#/components/schemas/V2EventFileWatcherUpdated" + }, + { + "$ref": "#/components/schemas/V2EventPtyCreated" + }, + { + "$ref": "#/components/schemas/V2EventPtyUpdated" + }, + { + "$ref": "#/components/schemas/V2EventPtyExited" + }, + { + "$ref": "#/components/schemas/V2EventPtyDeleted" + }, + { + "$ref": "#/components/schemas/V2EventQuestionV2Asked" + }, + { + "$ref": "#/components/schemas/V2EventQuestionV2Replied" + }, + { + "$ref": "#/components/schemas/V2EventQuestionV2Rejected" + }, + { + "$ref": "#/components/schemas/V2EventTodoUpdated" + }, + { + "$ref": "#/components/schemas/V2EventLspUpdated" + }, + { + "$ref": "#/components/schemas/V2EventPermissionAsked" + }, + { + "$ref": "#/components/schemas/V2EventPermissionReplied" + }, + { + "$ref": "#/components/schemas/V2EventTuiPromptAppend" + }, + { + "$ref": "#/components/schemas/V2EventTuiCommandExecute" + }, + { + "$ref": "#/components/schemas/V2EventTuiToastShow" + }, + { + "$ref": "#/components/schemas/V2EventTuiSessionSelect" + }, + { + "$ref": "#/components/schemas/V2EventMcpToolsChanged" + }, + { + "$ref": "#/components/schemas/V2EventMcpBrowserOpenFailed" + }, + { + "$ref": "#/components/schemas/V2EventCommandExecuted" + }, + { + "$ref": "#/components/schemas/V2EventProjectUpdated" + }, + { + "$ref": "#/components/schemas/V2EventSessionStatus" + }, + { + "$ref": "#/components/schemas/V2EventSessionIdle" + }, + { + "$ref": "#/components/schemas/V2EventQuestionAsked" + }, + { + "$ref": "#/components/schemas/V2EventQuestionReplied" + }, + { + "$ref": "#/components/schemas/V2EventQuestionRejected" + }, + { + "$ref": "#/components/schemas/V2EventSessionCompacted" + }, + { + "$ref": "#/components/schemas/V2EventVcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/V2EventWorkspaceReady" + }, + { + "$ref": "#/components/schemas/V2EventWorkspaceFailed" + }, + { + "$ref": "#/components/schemas/V2EventWorkspaceStatus" + }, + { + "$ref": "#/components/schemas/V2EventWorktreeReady" + }, + { + "$ref": "#/components/schemas/V2EventWorktreeFailed" + }, + { + "$ref": "#/components/schemas/V2EventServerConnected" + }, + { + "$ref": "#/components/schemas/V2EventGlobalDisposed" + }, + { + "$ref": "#/components/schemas/V2EventServerConnected" + } + ] + }, "ForbiddenError": { "type": "object", "properties": { @@ -27666,6 +27911,4926 @@ "required": ["name", "location", "content"], "additionalProperties": false }, + "V2EventModels-devRefreshed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventIntegrationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["integration.updated"] + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventCatalogUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["catalog.updated"] + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventMessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventMessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventMessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventMessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "messageID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextMoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.moved"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "subdirectory": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "location"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextPromptAdmitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextPromptPromoted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.promoted"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "timeCreated": { + "type": "number" + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextInterruptRequested": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.interrupt.requested"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextContextUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextToolFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventMessagePartDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionDiff": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionError": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventInstallationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventInstallationUpdate-available": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventFileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPluginAdded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPermissionV2Asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["permission.v2.asked"] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2Source" + } + }, + "required": ["id", "sessionID", "action", "resources"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPermissionV2Replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["permission.v2.replied"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "$ref": "#/components/schemas/PermissionV2Reply" + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventReferenceUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventProjectDirectoriesUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["project.directories.updated"] + }, + "data": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": ["projectID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventFileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPtyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPtyUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPtyDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventQuestionV2Asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["question.v2.asked"] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2Tool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventQuestionV2Replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["question.v2.replied"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Answer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventQuestionV2Rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["question.v2.rejected"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventTodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPermissionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventPermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventTuiPromptAppend": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventTuiCommandExecute": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "data": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventTuiToastShow": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventTuiSessionSelect": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventMcpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventMcpBrowserOpenFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "data": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventCommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "enum": ["git"] + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionStatus": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionIdle": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventQuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventQuestionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventQuestionRejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventSessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventVcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "data": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventWorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventWorkspaceFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventWorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "data": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventWorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventWorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, "QuestionV2Request": { "type": "object", "properties": { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 184837c54b5..2b39dd33b39 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -263,19 +263,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.text.delta": message.update(event.data.sessionID, (draft) => { - const match = message.latestText( - message.assistant(draft, event.data.assistantMessageID), - event.data.textID, - ) + const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID) if (match) match.text += event.data.delta }) break case "session.next.text.ended": message.update(event.data.sessionID, (draft) => { - const match = message.latestText( - message.assistant(draft, event.data.assistantMessageID), - event.data.textID, - ) + const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID) if (match) match.text = event.data.text }) break @@ -292,28 +286,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.input.delta": message.update(event.data.sessionID, (draft) => { - const match = message.latestTool( - message.assistant(draft, event.data.assistantMessageID), - event.data.callID, - ) + const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) if (match?.state.status === "pending") match.state.input += event.data.delta }) break case "session.next.tool.input.ended": message.update(event.data.sessionID, (draft) => { - const match = message.latestTool( - message.assistant(draft, event.data.assistantMessageID), - event.data.callID, - ) + const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) if (match?.state.status === "pending") match.state.input = event.data.text }) break case "session.next.tool.called": message.update(event.data.sessionID, (draft) => { - const match = message.latestTool( - message.assistant(draft, event.data.assistantMessageID), - event.data.callID, - ) + const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) if (!match) return match.time.ran = event.data.timestamp match.provider = event.data.provider @@ -322,10 +307,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.progress": message.update(event.data.sessionID, (draft) => { - const match = message.latestTool( - message.assistant(draft, event.data.assistantMessageID), - event.data.callID, - ) + const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) if (match?.state.status !== "running") return match.state.structured = event.data.structured match.state.content = [...event.data.content] @@ -333,10 +315,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.success": message.update(event.data.sessionID, (draft) => { - const match = message.latestTool( - message.assistant(draft, event.data.assistantMessageID), - event.data.callID, - ) + const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) if (match?.state.status !== "running") return match.state = { status: "completed", @@ -355,10 +334,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.failed": message.update(event.data.sessionID, (draft) => { - const match = message.latestTool( - message.assistant(draft, event.data.assistantMessageID), - event.data.callID, - ) + const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return match.state = { status: "error", @@ -403,8 +379,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) if (match) { match.text = event.data.text - if (event.data.providerMetadata !== undefined) - match.providerMetadata = event.data.providerMetadata + if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) break From e50261e5248bdb7942a9db04f587e295bf63d93b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 08:23:02 -0400 Subject: [PATCH 069/112] fix(core): format generated migrations --- packages/core/script/migration.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts index 48b555b5937..4f383f5f8f6 100644 --- a/packages/core/script/migration.ts +++ b/packages/core/script/migration.ts @@ -45,15 +45,17 @@ async function generate() { if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`) await Bun.write( target, - renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()), + await formatTypescript( + renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()), + ), ) await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot) } await fs.mkdir(full) await drizzle(temporary, full, "schema") - await Bun.write(schema, renderSchema(await generatedSql(full))) - await Bun.write(registry, renderRegistry(await typescriptMigrations())) + await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full)))) + await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations()))) } finally { await fs.rm(temporary, { recursive: true, force: true }) } @@ -76,12 +78,12 @@ async function check() { await fs.mkdir(full) await drizzle(temporary, full, "schema") - if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) { + if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) { throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.") } const migrations = await typescriptMigrations() - if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) { + if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) { throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") } } finally { @@ -170,6 +172,18 @@ function escapeTemplate(line: string) { return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") } +async function formatTypescript(input: string) { + const prettier = await import("prettier") + const typescript = await import("prettier/plugins/typescript") + const estree = await import("prettier/plugins/estree") + return prettier.format(input, { + parser: "typescript", + plugins: [typescript.default, estree.default], + semi: false, + printWidth: 120, + }) +} + function renderRegistry(names: string[]) { return `import type { DatabaseMigration } from "./migration" From 7b750a8f20e72d9842a3a18e00544fb9e43b0b7c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 08:35:40 -0400 Subject: [PATCH 070/112] fix(ci): preserve test log groups --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aeeccc42fec..5776e1bf242 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,7 +65,7 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task + run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} From 36264ccf90660b39a774abd4f50826a1b89bdbd8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:45:57 -0500 Subject: [PATCH 071/112] fix(stats): format market share tokens --- packages/stats/app/src/routes/index.tsx | 7 ++++--- packages/stats/core/src/domain/home.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 1d46d43a136..8e4b86a00af 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1450,9 +1450,7 @@ function formatCountryName(country: string) { } function formatGeoTokens(value: number) { - if (value >= 1) return formatTrillions(value) - if (value >= 0.001) return `${Number((value * 1000).toFixed(value >= 0.01 ? 0 : 1))}B` - return `${Math.round(value * 1_000_000)}M` + return formatTrillions(value) } function formatGeoShare(value: number) { @@ -1508,6 +1506,9 @@ function formatMarketMobileDate(label: string) { } function formatTrillions(value: number) { + if (value === 0) return "0" + if (value < 0.001) return `${Number((value * 1_000_000).toFixed(value >= 0.00001 ? 0 : 1))}M` + if (value < 1) return `${Number((value * 1_000).toFixed(value >= 0.01 ? 0 : 1))}B` return `${value.toFixed(value >= 10 ? 0 : 1)}T` } diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index f0d4994bb76..346d4da4f93 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -538,11 +538,11 @@ function buildMarketShare(rows: ProviderMetricRow[], product: UsageProduct, rang return [ { date: bucket.label, - total: round(totalTokens / 1_000_000_000_000, 2), + total: round(totalTokens / 1_000_000_000_000, 6), authors: withOther.map((item) => ({ author: item.provider === "Other" ? "Other" : formatProvider(item.provider), share: round((item.tokens / totalTokens) * 100, 1), - tokens: round(item.tokens / 1_000_000_000_000, 2), + tokens: round(item.tokens / 1_000_000_000_000, 6), })), }, ] From 79b55d4db89d5314b3cec17221080c98de5ea02f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 08:50:35 -0400 Subject: [PATCH 072/112] fix(tui): use bridged event stream for data --- packages/tui/src/context/data.tsx | 33 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 2b39dd33b39..05cf4afbebc 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -21,6 +21,7 @@ import type { import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" +import { useEvent } from "./event" import { createSignal, onCleanup, onMount } from "solid-js" type LocationData = { @@ -71,6 +72,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) const sdk = useSDK() + const events = useEvent() const [defaultLocation, setDefaultLocation] = createSignal({ directory: sdk.directory ?? process.cwd(), }) @@ -119,12 +121,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, } - function handleEvent(event: V2Event, metadata: { directory: string; workspace: string | undefined }) { + function handleEvent(event: V2Event) { switch (event.type) { case "catalog.updated": void Promise.all([ - result.location.model.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }), - result.location.provider.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }), + result.location.model.refresh(event.location), + result.location.provider.refresh(event.location), ]) break case "session.next.agent.switched": @@ -404,26 +406,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "integration.updated": void Promise.all([ - result.location.integration.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }), - result.location.model.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }), - result.location.provider.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }), + result.location.integration.refresh(event.location), + result.location.model.refresh(event.location), + result.location.provider.refresh(event.location), ]) break } } onMount(() => { - const controller = new AbortController() - onCleanup(() => controller.abort()) - void (async () => { - const events = await sdk.client.v2.event.subscribe({ signal: controller.signal }) - for await (const event of events.stream) { - handleEvent(event, { - directory: event.location?.directory ?? defaultLocation().directory, - workspace: event.location?.workspaceID, - }) - } - })().catch(() => {}) + const unsub = events.subscribe((event, metadata) => { + handleEvent({ + ...event, + data: event.properties, + location: { directory: metadata.directory, workspaceID: metadata.workspace }, + } as V2Event) + }) + onCleanup(unsub) }) const result = { From cf31029350820c6bfc0fbd0e052a79a067ee6116 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 09:04:29 -0400 Subject: [PATCH 073/112] fix(core): batch plugin shutdown --- packages/core/src/plugin.ts | 14 ++++++++++--- packages/core/test/plugin.test.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index c826ba513bf..0a02ddfa7f5 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -103,8 +103,16 @@ export const layer = Layer.effect( } }[keyof Hooks][] = [] const events = yield* EventV2.Service - const scope = yield* Scope.Scope const locks = KeyedMutex.makeUnsafe() + const scope = yield* Scope.make() + + // One registry-owned scope lets shutdown remove every plugin transform in one batch. + yield* Effect.addFinalizer((exit) => + Effect.gen(function* () { + hooks = [] + yield* State.batch(Scope.close(scope, exit)) + }), + ) const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { @@ -112,7 +120,7 @@ export const layer = Layer.effect( yield* locks.withLock(id)( Effect.gen(function* () { const existing = hooks.find((item) => item.id === id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) + if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) const childScope = yield* Scope.fork(scope) const result = yield* input.effect.pipe( Scope.provide(childScope), @@ -181,7 +189,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const existing = hooks.find((item) => item.id === id) hooks = hooks.filter((item) => item.id !== id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) + if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) }), ) }), diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 69b63683c76..d8fe74336bd 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -46,6 +46,40 @@ describe("PluginV2", () => { }), ) + it.effect("batches plugin state rebuilds when the registry layer finalizes", () => + Effect.gen(function* () { + let finalized = 0 + const values = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (value: string) => draft.values.push(value) }), + finalize: () => Effect.sync(() => finalized++), + }) + const layerScope = yield* Scope.fork(yield* Scope.Scope) + const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) + + yield* State.batch( + Effect.forEach( + ["first", "second"], + (id) => + plugin.add({ + id: PluginV2.ID.make(id), + effect: values + .transform((editor) => { + editor.add(id) + }) + .pipe(Effect.asVoid), + }), + { discard: true }, + ), + ) + finalized = 0 + + yield* Scope.close(layerScope, Exit.void) + expect(values.get().values).toEqual([]) + expect(finalized).toBe(1) + }), + ) + it.effect("serializes same-ID additions and leaves one removable attachment", () => Effect.gen(function* () { const values = state() From 0d32d1f2935cc18bb9567763fb16128647012b21 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 22 Jun 2026 16:27:56 +0200 Subject: [PATCH 074/112] cli: add --mini (#33353) --- packages/opencode/src/cli/cmd/attach.ts | 57 ++++++++- packages/opencode/src/cli/cmd/cmd.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 108 +++++++++++++----- .../opencode/src/cli/cmd/run/runtime.stdin.ts | 2 +- packages/opencode/src/cli/cmd/run/runtime.ts | 2 +- packages/opencode/src/cli/cmd/run/splash.ts | 2 +- packages/opencode/src/cli/cmd/run/types.ts | 2 +- packages/opencode/src/cli/cmd/tui.ts | 65 +++++++++++ .../__snapshots__/help-snapshots.test.ts.snap | 36 +++--- .../test/cli/help/help-snapshots.test.ts | 4 + packages/opencode/test/cli/tui/thread.test.ts | 61 +++++++++- 11 files changed, 286 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 278cee8a70b..6f5aea6a124 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -41,14 +41,31 @@ export const AttachCommand = cmd({ alias: ["u"], type: "string", describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + }) + .option("mini", { + type: "boolean", + describe: "start the minimal interactive interface", + default: false, + }) + .option("replay", { + type: "boolean", + hidden: true, + }) + .option("no-replay", { + type: "boolean", + describe: "disable mini session history replay on resume and after resize", + }) + .option("replay-limit", { + type: "number", + describe: "cap visible mini replay to the newest N messages", }), handler: async (args) => { - const { TuiConfig } = await import("@/config/tui") - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") + if (args.replay === true) { + UI.error("--replay is not supported; replay is enabled by default") process.exitCode = 1 return } + const noReplay = args.replay === false || args.noReplay === true const directory = (() => { if (!args.dir) return undefined @@ -60,6 +77,40 @@ export const AttachCommand = cmd({ return args.dir } })() + + if (args.mini) { + const { runMini } = await import("./run") + await runMini({ + attach: args.url, + directory, + password: args.password, + username: args.username, + continue: args.continue, + session: args.session, + fork: args.fork, + replay: noReplay ? false : undefined, + replayLimit: args.replayLimit, + }) + return + } + + const unsupported = [ + ["--no-replay", noReplay], + ["--replay-limit", args.replayLimit !== undefined], + ].find((entry) => entry[1])?.[0] + if (unsupported) { + UI.error(`${unsupported} requires --mini`) + process.exitCode = 1 + return + } + + const { TuiConfig } = await import("@/config/tui") + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exitCode = 1 + return + } + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const config = await TuiConfig.get() diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index 05af009b884..910787f9407 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -export type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[]; _?: Array } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 958632776bd..fad09c3a7ad 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,13 +1,13 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { FSUtil } from "@opencode-ai/core/fs-util" -// CLI entry point for `opencode run`. +// CLI entry point for `opencode run` and `opencode --mini`. // // Handles three modes: // 1. Non-interactive (default): sends a single prompt, streams events to // stdout, and exits when the session goes idle. -// 2. Interactive local (`--interactive`): boots the split-footer direct mode +// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode // with an in-process server (no external HTTP). -// 3. Interactive attach (`--interactive --attach`): connects to a running +// 3. Interactive attach (`opencode --mini --attach`): connects to a running // opencode server and runs interactive mode against it. // // Also supports `--command` for slash-command execution, `--format json` for @@ -217,21 +217,22 @@ export const RunCommand = effectCmd({ type: "boolean", describe: "show thinking blocks", }) + .option("mini", { + type: "boolean", + hidden: true, + default: false, + }) .option("replay", { type: "boolean", default: true, + hidden: true, describe: "replay interactive session history on resume and after resize (use --no-replay to disable)", }) .option("replay-limit", { type: "number", + hidden: true, describe: "cap visible interactive replay to the newest N messages", }) - .option("interactive", { - alias: ["i"], - type: "boolean", - describe: "run in direct interactive split-footer mode", - default: false, - }) .option("dangerously-skip-permissions", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", @@ -240,6 +241,7 @@ export const RunCommand = effectCmd({ .option("demo", { type: "boolean", default: false, + hidden: true, describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { @@ -252,7 +254,8 @@ export const RunCommand = effectCmd({ const localInstance = yield* InstanceRef yield* Effect.promise(async () => { const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") - const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const interactive = args.mini + const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false) const die = (message: string): never => { UI.error(message) process.exit(1) @@ -269,20 +272,24 @@ export const RunCommand = effectCmd({ .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") - if (args.interactive && args.command) { - die("--interactive cannot be used with --command") + if (interactive && args.command) { + die("--mini cannot be used with --command") } - if (args.demo && !args.interactive) { - die("--demo requires --interactive") + if (interactive && args._?.[0] !== "mini") { + die("--mini must be used without the run subcommand") } - if (args.interactive && args.format === "json") { - die("--interactive cannot be used with --format json") + if (args.demo && !interactive) { + die("--demo requires --mini") } - if (args["replay-limit"] !== undefined && !args.interactive) { - die("--replay-limit requires --interactive") + if (interactive && args.format === "json") { + die("--mini cannot be used with --format json") + } + + if (args["replay-limit"] !== undefined && !interactive) { + die("--replay-limit requires --mini") } if ( @@ -292,11 +299,11 @@ export const RunCommand = effectCmd({ die("--replay-limit must be a positive integer") } - if (args.interactive && !process.stdout.isTTY) { - die("--interactive requires a TTY stdout") + if (interactive && !process.stdout.isTTY) { + die("--mini requires a TTY stdout") } - if (args.interactive) { + if (interactive) { try { resolveInteractiveStdin().cleanup?.() } catch (error) { @@ -304,7 +311,7 @@ export const RunCommand = effectCmd({ } } - const replay = args.replay || args["replay-limit"] !== undefined + const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) const directory = (() => { @@ -393,7 +400,7 @@ export const RunCommand = effectCmd({ message = resolveRunInput(message, piped) ?? "" const initialInput = resolveRunInput(rawMessage, piped) - if (message.trim().length === 0 && !args.command && !args.interactive) { + if (message.trim().length === 0 && !args.command && !interactive) { UI.error("You must provide a message or a command") process.exit(1) } @@ -403,7 +410,7 @@ export const RunCommand = effectCmd({ process.exit(1) } - const rules: PermissionV1.Ruleset = args.interactive + const rules: PermissionV1.Ruleset = interactive ? [] : [ { @@ -801,7 +808,7 @@ export const RunCommand = effectCmd({ await share(client, sessionID) - if (!args.interactive) { + if (!interactive) { const events = await client.event.subscribe() const completed = loop(client, events).catch((e) => { console.error(e) @@ -875,7 +882,7 @@ export const RunCommand = effectCmd({ return } - if (args.interactive && !args.attach && !args.session && !args.continue) { + if (interactive && !args.attach && !args.session && !args.continue) { const model = pick(args.model) const { runInteractiveLocalMode } = await import("./run/runtime") const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -933,3 +940,52 @@ export const RunCommand = effectCmd({ }) }), }) + +type MiniCommandInput = { + directory?: string + attach?: string + password?: string + username?: string + continue?: boolean + session?: string + fork?: boolean + model?: string + agent?: string + prompt?: string + replay?: boolean + replayLimit?: number + demo?: boolean +} + +export async function runMini(input: MiniCommandInput) { + if (!RunCommand.handler) throw new Error("Mini command handler is unavailable") + await RunCommand.handler({ + $0: "opencode", + _: ["mini"], + message: input.prompt ? [input.prompt] : [], + command: undefined, + continue: input.continue, + session: input.session, + fork: input.fork, + share: undefined, + model: input.model, + agent: input.agent, + format: "default", + file: undefined, + title: undefined, + attach: input.attach, + password: input.password, + username: input.username, + dir: input.directory, + port: undefined, + variant: undefined, + thinking: undefined, + mini: true, + replay: input.replay ?? true, + "replay-limit": input.replayLimit, + replayLimit: input.replayLimit, + "dangerously-skip-permissions": false, + dangerouslySkipPermissions: false, + demo: input.demo ?? false, + }) +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts index dad46a7fb02..d236fb02c2e 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts @@ -1,7 +1,7 @@ import fs from "fs" import * as tty from "node:tty" -export const INTERACTIVE_INPUT_ERROR = "--interactive requires a controlling terminal for input" +export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input" type InteractiveStdin = { stdin: NodeJS.ReadStream diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 65cd15f1ad0..90cddffa222 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -1,4 +1,4 @@ -// Top-level orchestrator for `run --interactive`. +// Top-level orchestrator for `opencode --mini`. // // Wires the boot sequence, lifecycle (renderer + footer), stream transport, // and prompt queue together into a single session loop. Two entry points: diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/opencode/src/cli/cmd/run/splash.ts index 20194b95ce9..141ff6fc553 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/opencode/src/cli/cmd/run/splash.ts @@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback lines, body_left + label.length, top + 1, - `opencode run -i -s ${meta.session_id}`, + `opencode --mini -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD, diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 62e1a2d8a4c..a914922e487 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -1,4 +1,4 @@ -// Shared type vocabulary for the direct interactive mode (`run --interactive`). +// Shared type vocabulary for the direct interactive mode (`opencode --mini`). // // Direct mode uses a split-footer terminal layout: immutable scrollback for the // session transcript, and a mutable footer for prompt input, status, and diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 68941e976ac..329874791db 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -103,8 +103,73 @@ export const TuiThreadCommand = cmd({ .option("agent", { type: "string", describe: "agent to use", + }) + .option("mini", { + type: "boolean", + describe: "start the minimal interactive interface", + default: false, + }) + .option("replay", { + type: "boolean", + hidden: true, + }) + .option("no-replay", { + type: "boolean", + describe: "disable mini session history replay on resume and after resize", + }) + .option("replay-limit", { + type: "number", + describe: "cap visible mini replay to the newest N messages", + }) + .option("demo", { + type: "boolean", + hidden: true, }), handler: async (args) => { + if (args.replay === true) { + UI.error("--replay is not supported; replay is enabled by default") + process.exitCode = 1 + return + } + const noReplay = args.replay === false || args.noReplay === true + + if (args.mini) { + const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) => + process.argv.some((arg) => arg === option || arg.startsWith(option + "=")), + ) + if (network) { + UI.error(`${network} cannot be used with --mini`) + process.exitCode = 1 + return + } + + const { runMini } = await import("./run") + await runMini({ + directory: resolveThreadDirectory(args.project), + continue: args.continue, + session: args.session, + fork: args.fork, + model: args.model, + agent: args.agent, + prompt: args.prompt, + replay: noReplay ? false : undefined, + replayLimit: args.replayLimit, + demo: args.demo, + }) + return + } + + const unsupported = [ + ["--no-replay", noReplay], + ["--replay-limit", args.replayLimit !== undefined], + ["--demo", args.demo !== undefined], + ].find((entry) => entry[1])?.[0] + if (unsupported) { + UI.error(`${unsupported} requires --mini`) + process.exitCode = 1 + return + } + const unguard = win32InstallCtrlCGuard() try { const { TuiConfig } = await import("@/config/tui") diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index a672d2acae5..25a4c38f907 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -50,17 +50,21 @@ Positionals: url http://localhost:4096 [string] [required] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --dir directory to run in [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]" + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --dir directory to run in [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') + [string] + --mini start the minimal interactive interface [boolean] [default: false] + --no-replay disable mini session history replay on resume and after resize [boolean] + --replay-limit cap visible mini replay to the newest N messages [number]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` @@ -103,16 +107,8 @@ Options: --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string] --thinking show thinking blocks [boolean] - --replay replay interactive session history on resume and after resize - (use --no-replay to disable) [boolean] [default: true] - --replay-limit cap visible interactive replay to the newest N messages - [number] - -i, --interactive run in direct interactive split-footer mode - [boolean] [default: false] --dangerously-skip-permissions auto-approve permissions that are not explicitly denied - (dangerous!) [boolean] [default: false] - --demo enable direct interactive demo slash commands; pass one as the - message to run it immediately [boolean] [default: false]" + (dangerous!) [boolean] [default: false]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = ` diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index edd92120adc..a2626b113da 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -102,6 +102,10 @@ describe("opencode CLI help-text snapshots", () => { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) expect(topLevel.stderr.endsWith(EOL)).toBe(true) + expect(topLevel.stderr).toContain("--mini") + expect(topLevel.stderr).not.toContain("--thinking") + expect(topLevel.stderr).not.toContain("--variant") + expect(topLevel.stderr).not.toContain("--demo") const argvs: Array = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS] diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index f79fd40da74..73f87d904bf 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" +import yargs from "yargs" import { tmpdir } from "../../fixture/fixture" -import { resolveThreadDirectory } from "../../../src/cli/cmd/tui" +import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" +import { cliIt } from "../../lib/cli-process" describe("tui thread", () => { test("loads the TUI integration lazily", async () => { @@ -33,4 +36,60 @@ describe("tui thread", () => { test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) + + test("resolves a relative mini project from PWD when cwd differs", async () => { + await using pwd = await tmpdir({ git: true }) + await using cwd = await tmpdir({ git: true }) + + expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(pwd.path) + expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) + }) + + test("parses supported --no-replay forms", async () => { + for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { + const args = await yargs([]) + .command({ ...TuiThreadCommand, handler: () => {} }) + .exitProcess(false) + .parse(["--mini", option, "--replay-limit", "10"]) + + expect(args.replay === false || args.noReplay === true).toBe(true) + expect(args.replayLimit).toBe(10) + } + }) + + test("preserves boolean negation for existing options", async () => { + const args = await yargs([]) + .command({ ...TuiThreadCommand, handler: () => {} }) + .exitProcess(false) + .parse(["--mdns", "--no-mdns"]) + + expect(args.mdns).toBe(false) + }) + + cliIt.live("rejects mini-only options without --mini", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--replay-limit", "10"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--replay-limit requires --mini") + }), + ) + + cliIt.live("routes attached sessions to mini mode", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--mini requires a TTY stdout") + }), + ) + + cliIt.live("rejects network options in mini mode", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--mini", "--port", "4096"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--port cannot be used with --mini") + }), + ) }) From 41d1279b6f498bf7a03db50948d2f521d62ceef0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 16:58:22 +0200 Subject: [PATCH 075/112] fix(opencode): preserve request logger context (#33381) --- packages/opencode/src/server/routes/instance/httpapi/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 09e25de8cb8..b74df8deb83 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -281,7 +281,7 @@ export function createRoutes( ]), Layer.provide(LayerNode.buildLayer(app)), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), - Layer.provide(Observability.layer), + Layer.provideMerge(Observability.layer), ) } From d5980b47e9616029d797206a3caf795dc9a573c3 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 22 Jun 2026 17:03:19 +0200 Subject: [PATCH 076/112] feat(llm): add video and audio media support to Gemini protocol (#31889) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/llm/src/protocols/gemini.ts | 6 +++--- packages/llm/src/protocols/shared.ts | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c8fa34b5096..3a2311c8fdc 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -21,7 +21,7 @@ import { GeminiToolSchema } from "./utils/gemini-tool-schema" import { Lifecycle } from "./utils/lifecycle" const ADAPTER = "gemini" -const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) +const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" // ============================================================================= @@ -182,7 +182,7 @@ const lowerToolConfig = (toolChoice: NonNullable) => const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) { if (part.type === "text") return { text: part.text } - const media = yield* ProviderShared.validateMedia("Gemini", part, IMAGE_MIMES) + const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES) return { inlineData: { mimeType: media.mime, data: media.base64 } } }) @@ -275,7 +275,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR }) for (const item of content) { if (item.type === "text") continue - const media = yield* ProviderShared.validateToolFile("Gemini", item, IMAGE_MIMES) + const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES) parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } }) } } diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 4a1fed55398..66b353c8285 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -188,8 +188,11 @@ export const parseToolInput = (route: string, name: string, raw: string) => parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`) export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const -export const MAX_MEDIA_ENCODED_BYTES = 8 * 1024 * 1024 -export const MAX_MEDIA_DECODED_BYTES = 6 * 1024 * 1024 +export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const +export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const +export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const +export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024 +export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024 const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ From f9f22804526c88a89e28af846133c3c91e1d3803 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 17:07:27 +0200 Subject: [PATCH 077/112] fix(core): defer session model validation (#33377) --- packages/core/src/public/opencode.ts | 75 ++-------- packages/core/src/public/session.ts | 24 +--- packages/core/src/session/runner/model.ts | 70 +++++++-- packages/core/test/location-layer.test.ts | 56 +++++++- packages/core/test/public-opencode.test.ts | 133 +++--------------- .../core/test/session-runner-model.test.ts | 37 +++-- 6 files changed, 171 insertions(+), 224 deletions(-) diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 7388705d8c4..7d32556340f 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -1,11 +1,9 @@ export * as OpenCode from "./opencode" import { Context, Effect, Layer } from "effect" -import { Catalog } from "../catalog" import { Database } from "../database/database" import { EventV2 } from "../event" import { LocationServiceMap } from "../location-layer" -import { PluginBoot } from "../plugin/boot" import { ProjectV2 } from "../project" import { SessionV2 } from "../session" import * as SessionExecutionLocal from "../session/execution/local" @@ -23,69 +21,22 @@ export interface Interface { /** Intentional public native API for Effect applications embedding OpenCode. */ export class Service extends Context.Service()("@opencode/public/OpenCode") {} -class SessionModelValidation extends Context.Service< - SessionModelValidation, - { - readonly validate: ( - input: Session.SwitchModelInput & { readonly location: Session.Info["location"] }, - ) => Effect.Effect - } ->()("@opencode/public/OpenCode/SessionModelValidation") {} - -const ApplicationToolsLayer = ApplicationTools.layer -const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer)) -const SessionModelValidationLayer = Layer.effect( - SessionModelValidation, - Effect.gen(function* () { - const locations = yield* LocationServiceMap - return SessionModelValidation.of({ - validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) { - yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() - const catalog = yield* Catalog.Service - const model = (yield* catalog.model.available()).find( - (model) => model.providerID === input.model.providerID && model.id === input.model.id, - ) - if (!model) - return yield* new Session.ModelUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - }) - if ( - input.model.variant !== undefined && - input.model.variant !== "default" && - !model.variants.some((variant) => variant.id === input.model.variant) - ) - return yield* new Session.VariantUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - variant: input.model.variant, - }) - }).pipe(Effect.provide(locations.get(input.location))) - }), - }) - }), +const SessionsLayer = SessionV2.layer.pipe( + Layer.provide(SessionProjector.layer), + Layer.provide(SessionExecutionLocal.layer), + Layer.provide(SessionStore.layer), + Layer.provide(EventV2.layer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectV2.defaultLayer), + Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))), + Layer.orDie, ) - -const SessionsLayer = Layer.merge( - SessionV2.layer.pipe( - Layer.provide(SessionProjector.layer), - Layer.provide(SessionExecutionLocal.layer), - Layer.provide(SessionStore.layer), - Layer.provide(EventV2.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, - ), - SessionModelValidationLayer, -).pipe(Layer.provide(LocationServicesLayer)) // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. export const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* SessionV2.Service const tools = yield* ApplicationTools.Service - const validation = yield* SessionModelValidation return Service.of({ tools: { register: tools.register }, sessions: { @@ -98,11 +49,7 @@ export const layer = Layer.effect( }), get: sessions.get, list: sessions.list, - switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { - const session = yield* sessions.get(input.sessionID) - yield* validation.validate({ ...input, location: session.location }) - yield* sessions.switchModel(input) - }), + switchModel: sessions.switchModel, interrupt: sessions.interrupt, prompt: (input) => sessions.prompt({ @@ -124,6 +71,6 @@ export const layer = Layer.effect( }, }) }), -).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer))) +).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer))) // TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts index 2610cec004e..212583b5593 100644 --- a/packages/core/src/public/session.ts +++ b/packages/core/src/public/session.ts @@ -1,7 +1,6 @@ export * as Session from "./session" -import { Effect, Schema, Stream } from "effect" -import { ModelV2 } from "../model" +import { Effect, Stream } from "effect" import { SessionV2 } from "../session" import { MessageDecodeError } from "../session/error" import { SessionEvent } from "../session/event" @@ -41,23 +40,6 @@ export type NotFoundError = SessionV2.NotFoundError export const PromptConflictError = SessionV2.PromptConflictError export type PromptConflictError = SessionV2.PromptConflictError -export class ModelUnavailableError extends Schema.TaggedErrorClass()( - "Session.ModelUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - }, -) {} - -export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "Session.VariantUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - variant: ModelV2.VariantID, - }, -) {} - export { MessageDecodeError } export interface CreateInput { @@ -104,9 +86,7 @@ export interface Interface { readonly get: (sessionID: ID) => Effect.Effect readonly list: (input?: ListInput) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: ( - input: SwitchModelInput, - ) => Effect.Effect + readonly switchModel: (input: SwitchModelInput) => Effect.Effect /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */ readonly interrupt: (sessionID: ID) => Effect.Effect readonly messages: (input: MessagesInput) => Effect.Effect diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 787c62c1909..968933a6b52 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -5,7 +5,7 @@ import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-message import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" import { Credential } from "../../credential" @@ -24,6 +24,23 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.ModelUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + }, +) {} + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.VariantUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + variant: ModelV2.VariantID, + }, +) {} + export class UnsupportedApiError extends Schema.TaggedErrorClass()( "SessionRunnerModel.UnsupportedApiError", { @@ -33,7 +50,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass Effect.Effect @@ -70,13 +87,27 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { }) } -const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => { +const withVariant = ( + model: ModelV2.Info, + variantID: ModelV2.VariantID | undefined, +): Effect.Effect => { const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID const variant = model.variants.find((item) => item.id === id) - if (!variant) return model - return produce(model, (draft) => { - ModelRequest.assign(draft.request, variant) - }) + if (!variant && variantID !== undefined && variantID !== "default") + return Effect.fail( + new VariantUnavailableError({ + providerID: model.providerID, + modelID: model.id, + variant: variantID, + }), + ) + return Effect.succeed( + variant + ? produce(model, (draft) => { + ModelRequest.assign(draft.request, variant) + }) + : model, + ) } const apiName = (model: ModelV2.Info) => @@ -124,8 +155,15 @@ export const fromCatalogModel = ( ) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) => - fromCatalogModel(withVariant(model, session.model?.variant)) +export const resolve = ( + session: SessionSchema.Info, + model: ModelV2.Info, + connection?: IntegrationConnection.Info, + credential?: Credential.Info, +) => + withVariant(model, session.model?.variant).pipe( + Effect.flatMap((model) => fromCatalogModel(model, connection, credential)), + ) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && @@ -147,14 +185,22 @@ export const locationLayer = Layer.effect( yield* boot.wait() const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model - ? yield* catalog.model.get(session.model.providerID, session.model.id) + ? (yield* catalog.model.available()).find( + (model) => model.providerID === session.model?.providerID && model.id === session.model.id, + ) : defaultModel && supported(defaultModel) ? defaultModel : (yield* catalog.model.available()).find(supported) + if (!selected && session.model) + return yield* new ModelUnavailableError({ + providerID: session.model.providerID, + modelID: session.model.id, + }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) - return yield* fromCatalogModel( - withVariant(selected, session.model?.variant), + return yield* resolve( + session, + selected, connection, connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, ) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 21acc40ee71..0b3e0c8e54f 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,16 +1,20 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" +import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" import { Tool } from "@opencode-ai/core/public" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { toolDefinitions } from "./lib/tool" @@ -136,6 +140,56 @@ describe("LocationServiceMap", () => { ), ) + it.live("rejects an unavailable selected model during location model resolution", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + yield* Effect.promise(() => + fs.writeFile( + path.join(dir.path, "opencode.json"), + JSON.stringify({ + providers: { + unavailable: { + name: "Unavailable", + api: { type: "native", settings: {} }, + models: { chat: { disabled: true } }, + }, + }, + }), + ), + ) + const failure = yield* SessionRunnerModel.Service.use((models) => + models.resolve( + SessionV2.Info.make({ + id: SessionV2.ID.make("ses_unavailable_model"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: ModelV2.ID.make("chat"), + providerID: ProviderV2.ID.make("unavailable"), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location, + }), + ), + ).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.ModelUnavailableError", + providerID: "unavailable", + modelID: "chat", + }) + }), + ), + ), + ) + it.live("installs public plugins into a location", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index c5f90e92c48..fb9397e5727 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/packages/core/test/public-opencode.test.ts @@ -1,9 +1,6 @@ -import fs from "fs/promises" -import path from "path" import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public" -import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const it = testEffect(OpenCode.layer) @@ -41,94 +38,24 @@ describe("public native OpenCode API", () => { }), ) - it.effect("switches to an available model and variant", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - yield* writeProvider(tmp.path) - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_available") - const model = ref({ variant: "fast" }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), - }) + it.effect("records model selection without resolving the Location catalog", () => + Effect.gen(function* () { + const opencode = yield* OpenCode.Service + const sessionID = Session.ID.make("ses_public_switch_deferred") + const model = Schema.decodeUnknownSync(Model.Ref)({ + id: "missing", + providerID: "missing", + variant: "unknown", + }) + yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }), + }) - yield* opencode.sessions.switchModel({ sessionID, model }) + yield* opencode.sessions.switchModel({ sessionID, model }) - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) - }), - ), - ), - ) - - it.effect("rejects missing and Location-disabled models without changing the Session", () => - Effect.acquireRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), - ).pipe( - Effect.flatMap(([available, disabled]) => - Effect.gen(function* () { - yield* writeProvider(available.path) - yield* writeProvider(disabled.path, true) - const opencode = yield* OpenCode.Service - const availableID = Session.ID.make("ses_public_switch_exact_available") - const disabledID = Session.ID.make("ses_public_switch_exact_disabled") - yield* opencode.sessions.create({ - id: availableID, - location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }), - }) - yield* opencode.sessions.create({ - id: disabledID, - location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }), - }) - - yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) }) - const disabledError = yield* opencode.sessions - .switchModel({ sessionID: disabledID, model: ref() }) - .pipe(Effect.flip) - const missingError = yield* opencode.sessions - .switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) }) - .pipe(Effect.flip) - - expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError) - expect(missingError).toBeInstanceOf(Session.ModelUnavailableError) - expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" })) - expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined() - }), - ), - ), - ) - - it.effect("rejects an unavailable variant without changing the Session", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - yield* writeProvider(tmp.path) - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_variant") - const selected = ref({ variant: "fast" }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), - }) - yield* opencode.sessions.switchModel({ sessionID, model: selected }) - - const error = yield* opencode.sessions - .switchModel({ sessionID, model: ref({ variant: "unknown" }) }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Session.VariantUnavailableError) - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected) - }), - ), - ), + expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) + }), ) it.effect("preserves the typed not-found error for a missing Session", () => @@ -147,31 +74,3 @@ describe("public native OpenCode API", () => { }), ) }) - -const ref = (input: { id?: string; variant?: string } = {}) => - Schema.decodeUnknownSync(Model.Ref)({ - id: input.id ?? "chat", - providerID: "public-test", - variant: input.variant, - }) - -const writeProvider = (directory: string, disabled = false) => - Effect.promise(() => - fs.writeFile( - path.join(directory, "opencode.json"), - JSON.stringify({ - providers: { - "public-test": { - name: "Public test", - api: { type: "native", settings: {} }, - models: { - chat: { - disabled, - variants: [{ id: "fast" }], - }, - }, - }, - }, - }), - ), - ) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 50e60a3616a..e67ade131bc 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -43,14 +43,6 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => limit: { context: 100, output: 20 }, }) -const provider = (api: ProviderV2.Info["api"]) => - new ProviderV2.Info({ - id: ProviderV2.ID.make("test-provider"), - name: "Test provider", - api, - request: { headers: {}, body: {} }, - }) - describe("SessionRunnerModel", () => { it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () => Effect.gen(function* () { @@ -194,6 +186,35 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("rejects an explicit unavailable Session variant during model resolution", () => + Effect.gen(function* () { + const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_model_variant_unavailable"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: catalog.id, + providerID: catalog.providerID, + variant: ModelV2.VariantID.make("unknown"), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + + const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.VariantUnavailableError", + providerID: "test-provider", + modelID: "test-model", + variant: "unknown", + }) + }), + ) + it.effect("lowers selected Anthropic Session variants into Messages options", () => Effect.gen(function* () { const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [ From c6ee511485bfb76fb89abfad2859463671f1cd3f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 17:34:03 +0200 Subject: [PATCH 078/112] refactor(core): simplify session context epochs (#33378) --- CONTEXT.md | 15 +- packages/core/schema.json | 219 +++++++---- packages/core/src/database/migration.gen.ts | 1 + ...22142730_simplify_session_context_epoch.ts | 13 + packages/core/src/database/schema.gen.ts | 3 - packages/core/src/session/context-epoch.ts | 263 +++---------- packages/core/src/session/history.ts | 4 +- packages/core/src/session/projector.ts | 28 +- packages/core/src/session/runner/index.ts | 2 - packages/core/src/session/runner/llm.ts | 46 +-- packages/core/src/session/sql.ts | 3 - packages/core/test/database-migration.test.ts | 4 +- packages/core/test/session-runner.test.ts | 365 ++---------------- specs/v2/schema-changelog.md | 20 + specs/v2/session.md | 15 +- specs/v2/todo.md | 8 +- 16 files changed, 281 insertions(+), 728 deletions(-) create mode 100644 packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts diff --git a/CONTEXT.md b/CONTEXT.md index 1c12ba641c1..faf8ce9d125 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta _Avoid_: System update, system notification, raw text diff **Context Epoch**: -The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition. +The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline. **Baseline System Context**: The full **System Context** rendered at the start of a **Context Epoch**. @@ -75,31 +75,28 @@ The host-supplied environment overlay applied by the server when creating a PTY, - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. - `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked. -- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context. -- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run. +- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. -- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. -- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent. -- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline. +- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn. +- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline. - Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - A **Context Epoch** begins with one immutable **Baseline System Context**. -- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. -- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. -- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. +- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. +- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. diff --git a/packages/core/schema.json b/packages/core/schema.json index c041a4e0118..5f71bfdb619 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,10 @@ { "version": "7", "dialect": "sqlite", - "id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db", - "prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"], + "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad", + "prevIds": [ + "169a0f0f-d58f-479f-b024-fa1c7b9a09db" + ], "ddl": [ { "name": "workspace", @@ -900,16 +902,6 @@ "entityType": "columns", "table": "session_context_epoch" }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'build'", - "generated": null, - "name": "agent", - "entityType": "columns", - "table": "session_context_epoch" - }, { "type": "text", "notNull": true, @@ -930,26 +922,6 @@ "entityType": "columns", "table": "session_context_epoch" }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "replacement_seq", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "revision", - "entityType": "columns", - "table": "session_context_epoch" - }, { "type": "text", "notNull": false, @@ -1511,9 +1483,13 @@ "table": "session_share" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1522,9 +1498,13 @@ "table": "workspace" }, { - "columns": ["active_account_id"], + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1533,9 +1513,13 @@ "table": "account_state" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1544,9 +1528,13 @@ "table": "event" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1555,9 +1543,13 @@ "table": "permission" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1566,9 +1558,13 @@ "table": "project_directory" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1577,9 +1573,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1588,9 +1588,13 @@ "table": "part" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1599,9 +1603,13 @@ "table": "session_context_epoch" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1610,9 +1618,13 @@ "table": "session_input" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1621,9 +1633,13 @@ "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1632,9 +1648,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1643,9 +1663,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1654,133 +1678,174 @@ "table": "session_share" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["project_id", "directory"], + "columns": [ + "project_id", + "directory" + ], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["name"], + "columns": [ + "name" + ], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_context_epoch_pk", "table": "session_context_epoch", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1e915bb3cf6..fd778414aa9 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -37,5 +37,6 @@ export const migrations = ( import("./migration/20260611035744_credential"), import("./migration/20260611192811_lush_chimera"), import("./migration/20260612174303_project_dir_strategy"), + import("./migration/20260622142730_simplify_session_context_epoch"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts b/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts new file mode 100644 index 00000000000..1520bac4c14 --- /dev/null +++ b/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260622142730_simplify_session_context_epoch", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`) + yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`) + yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 5c044ec60f9..ed60fde6c55 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -149,11 +149,8 @@ export default { CREATE TABLE \`session_context_epoch\` ( \`session_id\` text PRIMARY KEY, \`baseline\` text NOT NULL, - \`agent\` text DEFAULT 'build' NOT NULL, \`snapshot\` text NOT NULL, \`baseline_seq\` integer NOT NULL, - \`replacement_seq\` integer, - \`revision\` integer DEFAULT 0 NOT NULL, CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 1fb8df92e6e..18624706a97 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -1,54 +1,31 @@ export * as SessionContextEpoch from "./context-epoch" -import { and, eq, isNull, lt, or, sql } from "drizzle-orm" +import { eq } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { AgentV2 } from "../agent" import type { Database } from "../database/database" import { EventV2 } from "../event" -import { Location } from "../location" import { SystemContext } from "../system-context/index" import { ContextSnapshotDecodeError } from "./error" import { SessionEvent } from "./event" +import { SessionHistory } from "./history" import { SessionInput } from "./input" import { SessionMessageID } from "./message-id" import { SessionSchema } from "./schema" -import { SessionContextEpochTable, SessionTable } from "./sql" +import { SessionContextEpochTable } from "./sql" type DatabaseService = Database.Interface["db"] -class RevisionMismatch extends Error {} -class LocationMismatch extends Error {} -export class AgentMismatch extends Error {} -export class AgentReplacementBlocked extends Schema.TaggedErrorClass()( - "SessionContextEpoch.AgentReplacementBlocked", - { sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID }, -) {} - -const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => - attempt().pipe( - Effect.catchDefect((defect) => - defect instanceof RevisionMismatch - ? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt))) - : Effect.die(defect), - ), - ) - interface Prepared { readonly baseline: string readonly baselineSeq: number - readonly revision: number } export function initialize( db: DatabaseService, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, ): Effect.Effect { - return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe( - Effect.withSpan("SessionContextEpoch.initialize"), - ) + return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize")) } export function prepare( @@ -56,12 +33,8 @@ export function prepare( events: EventV2.Interface, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, -): Effect.Effect { - return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe( - Effect.withSpan("SessionContextEpoch.prepare"), - ) +): Effect.Effect { + return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare")) } const prepareOnce = Effect.fnUntraced(function* ( @@ -69,57 +42,50 @@ const prepareOnce = Effect.fnUntraced(function* ( events: EventV2.Interface, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, ) { - const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" }) + const [value, stored, compaction] = yield* Effect.all( + [context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)], + { concurrency: "unbounded" }, + ) if (!stored) { const generation = yield* SystemContext.initialize(value) - const baselineSeq = yield* insert(db, sessionID, location, agent, generation) - return { baseline: generation.baseline, baselineSeq, revision: 0 } + const baselineSeq = yield* insert(db, sessionID, generation) + return { baseline: generation.baseline, baselineSeq } } const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })), ) - const replacingAgent = stored.agent !== agent - const result = - stored.replacement_seq === null && !replacingAgent - ? yield* SystemContext.reconcile(value, snapshot) - : yield* SystemContext.replace(value, snapshot) - if (result._tag === "ReplacementBlocked" && replacingAgent) { - yield* fence(db, sessionID, agent, stored.revision) - return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent }) - } + const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined + const result = replacementSeq + ? yield* SystemContext.replace(value, snapshot) + : yield* SystemContext.reconcile(value, snapshot) if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { - yield* fence(db, sessionID, agent, stored.revision) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision } + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } } if (result._tag === "ReplacementReady") { - const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID)) - yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation) - return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 } + const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID)) + yield* replace(db, sessionID, baselineSeq, result.generation) + return { baseline: result.generation.baseline, baselineSeq } } yield* events.publish( SessionEvent.ContextUpdated, { sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text }, - { commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) }, + { commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) }, ) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 } + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } }) const initializeOnce = Effect.fnUntraced(function* ( db: DatabaseService, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, ) { if (yield* exists(db, sessionID)) return const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) - const baselineSeq = yield* insert(db, sessionID, location, agent, generation) - return { baseline: generation.baseline, baselineSeq, revision: 0 } + const baselineSeq = yield* insert(db, sessionID, generation) + return { baseline: generation.baseline, baselineSeq } }) const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { @@ -142,39 +108,6 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic .pipe(Effect.orDie) }) -const requireAgentSelection = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, -) { - const selected = yield* db - .select({ agent: SessionTable.agent }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch()) -}) - -export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - seq: number, -) { - return yield* db - .update(SessionContextEpochTable) - .set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - lt(SessionContextEpochTable.baseline_seq, seq), - or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)), - ), - ) - .run() - .pipe(Effect.orDie) -}) - export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, @@ -189,155 +122,53 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( const insert = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, generation: SystemContext.Generation, ) { - return yield* db - .transaction( - () => - Effect.gen(function* () { - const placed = yield* db - .select({ agent: SessionTable.agent }) - .from(SessionTable) - .where( - and( - eq(SessionTable.id, sessionID), - eq(SessionTable.directory, location.directory), - location.workspaceID === undefined - ? isNull(SessionTable.workspace_id) - : eq(SessionTable.workspace_id, location.workspaceID), - ), - ) - .get() - .pipe(Effect.orDie) - if (!placed) return yield* Effect.die(new LocationMismatch()) - if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch()) - const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) - yield* db - .insert(SessionContextEpochTable) - .values({ - session_id: sessionID, - baseline: generation.baseline, - agent, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - revision: 0, - }) - .onConflictDoNothing() - .returning({ sessionID: SessionContextEpochTable.session_id }) - .get() - .pipe( - Effect.orDie, - Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))), - ) - return baselineSeq - }), - { behavior: "immediate" }, - ) + const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) + yield* db + .insert(SessionContextEpochTable) + .values({ + session_id: sessionID, + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + }) + .run() .pipe(Effect.orDie) + return baselineSeq }) const replace = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - agent: AgentV2.ID, - expectedRevision: number, baselineSeq: number, generation: SystemContext.Generation, ) { - yield* db - .transaction( - () => - Effect.gen(function* () { - yield* requireAgentSelection(db, sessionID, agent) - const updated = yield* db - .update(SessionContextEpochTable) - .set({ - baseline: generation.baseline, - agent, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - replacement_seq: null, - revision: expectedRevision + 1, - }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - eq(SessionContextEpochTable.revision, expectedRevision), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new RevisionMismatch()) - }), - { behavior: "immediate" }, - ) - .pipe(Effect.orDie) -}) - -const fence = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, - expectedRevision: number, -) { - const current = yield* db - .select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision }) - .from(SessionContextEpochTable) - .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!current || (current.selected !== null && current.selected !== agent)) - return yield* Effect.die(new AgentMismatch()) - if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch()) -}) - -export const current = Effect.fn("SessionContextEpoch.current")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, - revision: number, -) { - const value = yield* db - .select({ - agent: SessionContextEpochTable.agent, - selected: SessionTable.agent, - revision: SessionContextEpochTable.revision, + const updated = yield* db + .update(SessionContextEpochTable) + .set({ + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, }) - .from(SessionContextEpochTable) - .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) .where(eq(SessionContextEpochTable.session_id, sessionID)) + .returning({ sessionID: SessionContextEpochTable.session_id }) .get() .pipe(Effect.orDie) - return ( - value !== undefined && - value.agent === agent && - (value.selected === null || value.selected === agent) && - value.revision === revision - ) + if (!updated) return yield* Effect.die("Context Epoch not found") }) const advance = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - expectedRevision: number, snapshot: SystemContext.Snapshot, ) { const updated = yield* db .update(SessionContextEpochTable) - .set({ snapshot, revision: expectedRevision + 1 }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - eq(SessionContextEpochTable.revision, expectedRevision), - isNull(SessionContextEpochTable.replacement_seq), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) + .set({ snapshot }) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .returning({ sessionID: SessionContextEpochTable.session_id }) .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new RevisionMismatch()) + if (!updated) return yield* Effect.die("Context Epoch not found") }) diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 285c1bcd5c7..fb55ab07569 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"] const decode = Schema.decodeUnknownEffect(SessionMessage.Message) -const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { +export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* db - .select() + .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .orderBy(desc(SessionMessageTable.seq)) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index bffe4e74c6a..30af9f2e6db 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -329,19 +329,14 @@ export const layer = Layer.effectDiscard( if (next) yield* applyUsage(db, sessionID, next) }), ) - yield* events.project(SessionEvent.AgentSwitched, (event) => { - if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") - return db + yield* events.project(SessionEvent.AgentSwitched, (event) => + db .update(SessionTable) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() - .pipe( - Effect.orDie, - Effect.andThen(run(db, event)), - Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)), - ) - }) + .pipe(Effect.orDie, Effect.andThen(run(db, event))), + ) yield* events.project(SessionEvent.ModelSwitched, (event) => Effect.gen(function* () { yield* db @@ -351,8 +346,6 @@ export const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie) yield* run(db, event) - if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") - yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq) }), ) yield* events.project(SessionEvent.Prompted, (event) => @@ -407,7 +400,6 @@ export const layer = Layer.effectDiscard( }), ) yield* events.project(SessionEvent.InterruptRequested, () => Effect.void) - // TODO: Reconstruct context epoch replacement state during replay without adding replay state to every EventV2 payload. yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) @@ -426,15 +418,9 @@ export const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) - yield* events.project(SessionEvent.Compaction.Ended, (event) => { - if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") - if (event.durable.version === 1) return Effect.void - const seq = event.durable.seq - return Effect.gen(function* () { - yield* run(db, event) - yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq) - }) - }) + yield* events.project(SessionEvent.Compaction.Ended, (event) => + event.durable?.version === 1 ? Effect.void : run(db, event), + ) }), ) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 4060cc6b044..2210b57b34e 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -6,7 +6,6 @@ import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" import type { SystemContext } from "../../system-context/index" -import type { SessionContextEpoch } from "../context-epoch" import type { ToolOutputStore } from "../../tool-output-store" export type RunError = @@ -15,7 +14,6 @@ export type RunError = | MessageDecodeError | ContextSnapshotDecodeError | SystemContext.InitializationBlocked - | SessionContextEpoch.AgentReplacementBlocked | ToolOutputStore.Error /** Runs one local continuation from already-recorded Session history. */ diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 5d84e985a62..756a119280c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -8,7 +8,7 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" -import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect" +import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" @@ -141,8 +141,8 @@ export const layer = Layer.effect( cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) type TurnTransition = - // Request preparation observed a concurrent Session change and must restart from durable state. - | { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery } + // Automatic compaction completed; rebuild the request from compacted history. + | { readonly _tag: "ContinueAfterCompaction" } // Overflow compaction completed; rebuild once through the path without overflow recovery. | { readonly _tag: "ContinueAfterOverflowCompaction" } @@ -152,20 +152,11 @@ export const layer = Layer.effect( } } - const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) => - new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion }) + const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" }) const continueAfterOverflowCompaction = new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", }) - const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) => - Effect.catchDefect((defect) => - defect instanceof SessionContextEpoch.AgentMismatch - ? Effect.die(rebuildPreparedTurn(promotion)) - : Effect.die(defect), - ) - - const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref)) const loadSystemContext = (agent: AgentV2.Selection) => Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { concurrency: "unbounded", @@ -181,13 +172,7 @@ export const layer = Layer.effect( if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt const agent = yield* agents.select(session.agent) - const initialized = yield* SessionContextEpoch.initialize( - db, - loadSystemContext(agent), - session.id, - session.location, - agent.id, - ).pipe(retryAgentMismatch(promotion)) + const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) const toolFibers = yield* FiberSet.make() let needsContinuation = false if (promotion) { @@ -199,18 +184,7 @@ export const layer = Layer.effect( } } const system = - initialized ?? - (yield* SessionContextEpoch.prepare( - db, - events, - loadSystemContext(agent), - session.id, - session.location, - agent.id, - ).pipe(retryAgentMismatch(undefined))) - const current = yield* getSession(sessionID) - if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model)) - return yield* Effect.die(rebuildPreparedTurn()) + initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) @@ -228,7 +202,7 @@ export const layer = Layer.effect( toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) - return yield* Effect.die(rebuildPreparedTurn()) + return yield* Effect.die(continueAfterCompaction) const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: agent.id, @@ -242,8 +216,6 @@ export const layer = Layer.effect( const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => withPublication(publisher.publish(event, outputPaths)) let overflowFailure: ProviderErrorEvent | undefined - if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision))) - return yield* Effect.die(rebuildPreparedTurn()) const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -352,7 +324,7 @@ export const layer = Layer.effect( if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, step) }), ), ) @@ -366,7 +338,7 @@ export const layer = Layer.effect( yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* runAfterOverflowCompaction(sessionID, undefined, step) - return yield* runTurn(sessionID, defect.transition.promotion, step) + return yield* runTurn(sessionID, undefined, step) }), ), ) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index ca3d8e1b530..a9499554b42 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -170,9 +170,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", { .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), baseline: text().notNull(), - agent: text().$type().notNull().default(AgentV2.defaultID), snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), - replacement_seq: integer(), - revision: integer().notNull().default(0), }) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index d7126f76e26..914243a5899 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -71,9 +71,9 @@ describe("DatabaseMigration", () => { ).toEqual({ name: "session_context_epoch" }) expect( yield* db.get( - sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`, + sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`, ), - ).toEqual({ name: "agent", dflt_value: "'build'" }) + ).toBeUndefined() expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect( yield* db.all( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 862bb56d33d..f37a4c357a1 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -360,12 +360,12 @@ const setupOverflowRecovery = Effect.gen(function* () { return session }) -const userTexts = (request: LLMRequest) => +const messageTexts = (request: LLMRequest, role: "user" | "system") => request.messages.flatMap((message) => - message.role === "user" - ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) - : [], + message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [], ) +const userTexts = (request: LLMRequest) => messageTexts(request, "user") +const systemTexts = (request: LLMRequest) => messageTexts(request, "system") const replaySessionProjection = (id: SessionV2.ID) => Effect.gen(function* () { @@ -746,39 +746,6 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("does not create a source Location epoch after a concurrent Session move", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - let moved = false - systemLoadHook = Effect.suspend(() => { - if (moved) return Effect.void - moved = true - return events - .publish(SessionEvent.Moved, { - sessionID, - timestamp: DateTime.makeUnsafe(1), - location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true) - expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) - expect( - yield* db - .select() - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get(), - ).toBeUndefined() - expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved")) - }), - ) - it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { yield* setup @@ -890,7 +857,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("composes selected-agent skill guidance and replaces it after an agent switch", () => + it.effect("updates selected-agent skill guidance after an agent switch", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -913,12 +880,13 @@ describe("SessionRunnerLLM", () => { expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context\n\nBuild skills"], - ["Initial context\n\nReviewer skills"], + ["Initial context\n\nBuild skills"], ]) + expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills")) }), ) - it.effect("retries first-epoch preparation when the selected agent changes during observation", () => + it.effect("keeps the sampled agent when selection changes during observation", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -945,88 +913,12 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], + ["Initial context\n\nBuild skills"], ]) }), ) - it.effect("opens a queued activity once when the selected agent changes during observation", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - let switched = false - systemLoadHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: "reviewer", - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ - sessionID, - prompt: new Prompt({ text: "Queued" }), - delivery: "queue", - resume: false, - }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - - expect(requests).toHaveLength(1) - expect((yield* session.context(sessionID)).filter((message) => message.type === "user")).toHaveLength(1) - }), - ) - - it.effect("retries an agent switch before the final provider-dispatch boundary", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - let switched = false - modelResolveHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: "reviewer", - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], - ]) - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: null }) - }), - ) - - it.effect("retries a model switch before the final provider-dispatch boundary", () => + it.effect("keeps the sampled model when selection changes during model resolution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1049,145 +941,11 @@ describe("SessionRunnerLLM", () => { requests.length = 0 response = [] yield* session.resume(sessionID) - expect(requests.map((request) => request.model)).toEqual([replacementModel]) + expect(requests.map((request) => request.model)).toEqual([model]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]]) }), ) - it.effect("fences an unchanged epoch read across an agent ABA replacement request", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - let switched = false - systemLoadHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - .pipe( - Effect.andThen( - events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(2), - agent: AgentV2.defaultID, - }), - ), - Effect.asVoid, - ) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - requests.length = 0 - yield* session.resume(sessionID) - - expect(requests).toHaveLength(1) - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: null }) - }), - ) - - it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - const context = (text: string) => - Effect.succeed( - SystemContext.make({ - key: systemContextKey, - codec: Schema.toCodecJson(Schema.String), - load: Effect.succeed(text), - baseline: String, - update: (_previous, current) => current, - }), - ) - const location = (yield* session.get(sessionID)).location - - expect( - yield* SessionContextEpoch.prepare( - db, - events, - context("Stale build context"), - sessionID, - location, - AgentV2.defaultID, - ).pipe(Effect.catchDefect(Effect.succeed)), - ).toBeInstanceOf(SessionContextEpoch.AgentMismatch) - - expect( - yield* SessionContextEpoch.prepare( - db, - events, - context("Reviewer context"), - sessionID, - location, - AgentV2.ID.make("reviewer"), - ), - ).toMatchObject({ baseline: "Reviewer context" }) - }), - ) - - it.effect("blocks a cross-agent provider turn while replacement context is unavailable", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - skillBaselines.set(AgentV2.defaultID, "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - requests.length = 0 - const blocked = yield* session.resume(sessionID).pipe(Effect.exit) - expect(Exit.isFailure(blocked)).toBe(true) - if (Exit.isFailure(blocked)) - expect(Cause.squash(blocked.cause)).toBeInstanceOf(SessionContextEpoch.AgentReplacementBlocked) - expect(requests).toHaveLength(0) - - systemUnavailable = false - yield* session.resume(sessionID) - expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], - ]) - }), - ) - it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup @@ -1209,7 +967,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () => + it.effect("keeps the baseline and chronological System updates after a model switch", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1235,24 +993,26 @@ describe("SessionRunnerLLM", () => { expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) - expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"]) + expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2) expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ "user", "user", + "system", "model-switched", "user", + "system", ]) yield* replaySessionProjection(sessionID) - expect(yield* session.messages({ sessionID })).toHaveLength(5) + expect(yield* session.messages({ sessionID })).toHaveLength(6) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false }) yield* session.resume(sessionID) }), ) - it.effect("defers replacement while admitted context is temporarily unavailable", () => + it.effect("preserves the baseline while context is temporarily unavailable", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1279,81 +1039,12 @@ describe("SessionRunnerLLM", () => { expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) }), ) - it.effect("advances a pending replacement to the latest invalidation boundary", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, - }) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(2), - model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, - }) - const latest = yield* SessionInput.latestSeq(db, sessionID) - - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: latest }) - }), - ) - - it.effect("retries epoch preparation until observation-time invalidations settle", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - - requests.length = 0 - systemBaseline = "Changed context" - let invalidations = 0 - systemLoadHook = Effect.suspend(() => { - if (invalidations === 4) return Effect.void - invalidations++ - return events - .publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(invalidations), - model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") }, - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - yield* session.resume(sessionID) - - expect(invalidations).toBe(4) - expect(requests).toHaveLength(1) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"]) - }), - ) - - it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () => + it.effect("rebuilds the baseline directly after completed compaction", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1580,7 +1271,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("preserves effective System updates while compaction replacement is blocked", () => + it.effect("preserves effective System updates while compaction rebaseline is blocked", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1613,16 +1304,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"]) - expect( - requests - .at(-1) - ?.messages.some( - (message) => - message.role === "system" && - message.content[0]?.type === "text" && - message.content[0].text === "Changed context", - ), - ).toBe(true) + expect(systemTexts(requests.at(-1)!)).toContain("Changed context") }), ) @@ -1821,8 +1503,9 @@ describe("SessionRunnerLLM", () => { expect(requests.map((request) => request.model)).toEqual([model, replacementModel]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) + expect(systemTexts(requests[1]!)).toContain("Replacement context") }), ) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 4356c234a4e..a9a08b50139 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -800,3 +800,23 @@ Change: Compatibility: - Existing Context Epoch rows backfill the default `build` agent and reconcile to another selected agent at the next safe provider-turn boundary. + +## 2026-06-22: Simplify Session Context Rebaselining + +Affected schema: + +- Remove `session_context_epoch.agent`, `session_context_epoch.replacement_seq`, and `session_context_epoch.revision`. +- No synchronized event, public HTTP API, or generated SDK schema changes. + +Change: + +- Sample the effective agent and model once for each provider turn; selection changes apply to the next turn. +- Preserve the immutable baseline and admit ordinary System Context changes as chronological `ContextUpdated` messages. +- Rebuild the baseline directly after completed compaction instead of maintaining pending replacement state. +- Preserve the old baseline and its effective chronological updates while a post-compaction baseline cannot be rendered completely. +- Rely on the process-local Session execution lane instead of optimistic concurrency state between Context Epoch writers. + +Compatibility: + +- Existing Context Epoch rows migrate in place by dropping the obsolete selection and pending-replacement columns. +- Model and agent switches no longer discard earlier chronological System Context updates by forcing a new baseline. diff --git a/specs/v2/session.md b/specs/v2/session.md index ed30890fa64..43c93b17517 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -46,7 +46,7 @@ Projected hosted tools preserve call-side and settlement-side provider metadata ## Context Epochs -V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one effective agent, one immutable baseline, and a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission. +V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch stores one immutable provider-cache baseline and a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission. The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically. @@ -72,7 +72,7 @@ Client Runner System Context Registry C │ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶ ``` -Agent switches, model switches, and completed compactions request lazy baseline replacement. A switch admitted after the current safe provider-turn boundary applies to the next provider turn while leaving the already-prepared baseline durable. Before another cross-agent provider turn, the replacement must complete; unavailable admitted context blocks instead of exposing the prior agent's privileged baseline. A Session move clears the epoch so the destination Location must initialize a complete baseline before another provider turn. Epoch creation and replacement are fenced against the authoritative Session Location/effective agent and the epoch revision, preventing stale or ABA-observed context from becoming durable. +Agent and model selection are provider-turn scoped. A switch admitted after the current safe provider-turn boundary applies to the next provider turn without restarting the current turn or replacing the baseline. Agent-specific skill guidance remains a Context Source, so changed guidance is admitted as a chronological System message. A completed compaction causes the next provider attempt to render a fresh baseline directly from current complete context. A Session move clears the epoch so the destination Location initializes a complete baseline on its next run. ```text Session Epoch @@ -83,11 +83,8 @@ Session Epoch │ │ reconcile chronological update │ │ ◀─────────────────────────────────╯ │ │ - ├─ request replacement ───────────▶ - │ │ - │ ├─────────────────────────────────────╮ - │ │ replace after complete observation │ - │ ◀─────────────────────────────────────╯ + ├─ completed compaction ──────────▶ + │ ├─ render fresh baseline │ │ ├─ clear after Location move ─────▶ ``` @@ -110,7 +107,7 @@ Before each provider turn, the runner estimates the complete model-visible reque Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes. -`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.2` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message and requests Context Epoch replacement. A failed or interrupted attempt therefore leaves the previous history boundary active. +`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.2` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active. Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn. @@ -138,7 +135,7 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o | Per-turn request assembly | Plugin message, system, parameter, and header transforms | missing | Design V2 plugin hooks and lifecycle semantics. | | Per-turn request assembly | Model variants and request settings | partial | Apply effective agent options and future plugin-mutated request settings. | | Per-turn request assembly | Structured-output policy | missing | Add prompt format, generated tool, tool choice, and model-visible policy together. | -| Per-turn request assembly | Automatic/context-pressure compaction | partial | V2 replays completed compactions and replaces epochs but cannot initiate compaction. | +| Per-turn request assembly | Automatic/context-pressure compaction | complete | V2 initiates automatic and overflow-triggered compaction, then rebuilds the baseline from the completed checkpoint. | | Prompt/reference expansion | Durable typed prompt attachments | complete | None. | | Prompt/reference expansion | Native template and `@` mention expansion | missing | Parse and resolve native V2 prompt input before durable admission. | | Prompt/reference expansion | File, directory, media, and MCP-resource materialization | partial | Materialize and normalize sources instead of lowering unresolved attachment metadata. | diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 002139cdd5e..5d77cbea3d7 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -55,8 +55,8 @@ Next reviewed slices: - integrate the new BackgroundJob service with V2 tool execution: support background bash jobs and background agent dispatch with durable status observation, completion delivery, and explicit cancellation / continuation semantics -- add compaction, durable/clustered interruption, retries, and stale-owner fencing - only as their slices become concrete +- add durable/clustered interruption, retries, and stale-owner fencing only as + their slices become concrete ### Deferred durable activity recovery @@ -75,10 +75,6 @@ Design post-crash activity recovery as one explicit slice. It should model: - retry budget, backoff, visible recovery status, startup discovery, and future clustered ownership fencing -## Rework compaction - Aiden? - -The new agent loop needs to trigger compaction properly - ## Plugin API design - James? We need to figure out how we want server plugins to work and what hooks are useful. From 4d5efba5c9c60cb3ab5e89a5c4fc3675e3a591a4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 22 Jun 2026 15:36:00 +0000 Subject: [PATCH 079/112] chore: generate --- packages/core/schema.json | 187 ++++++++++---------------------------- 1 file changed, 46 insertions(+), 141 deletions(-) diff --git a/packages/core/schema.json b/packages/core/schema.json index 5f71bfdb619..d0eeeebd5c4 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad", - "prevIds": [ - "169a0f0f-d58f-479f-b024-fa1c7b9a09db" - ], + "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"], "ddl": [ { "name": "workspace", @@ -1483,13 +1481,9 @@ "table": "session_share" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1498,13 +1492,9 @@ "table": "workspace" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1513,13 +1503,9 @@ "table": "account_state" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1528,13 +1514,9 @@ "table": "event" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1543,13 +1525,9 @@ "table": "permission" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1558,13 +1536,9 @@ "table": "project_directory" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1573,13 +1547,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1588,13 +1558,9 @@ "table": "part" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1603,13 +1569,9 @@ "table": "session_context_epoch" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1618,13 +1580,9 @@ "table": "session_input" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1633,13 +1591,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1648,13 +1602,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1663,13 +1613,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1678,174 +1624,133 @@ "table": "session_share" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "project_id", - "directory" - ], + "columns": ["project_id", "directory"], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "name" - ], + "columns": ["name"], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_context_epoch_pk", "table": "session_context_epoch", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", From adebb87191098de416b59200495b19b4e24c2fb5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 17:37:43 +0200 Subject: [PATCH 080/112] test(opencode): use EventV2 location contract (#33383) --- .../test/server/httpapi-v2-location.test.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index 503ecb78ec8..e178062e7a1 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" import { Context, Schema } from "effect" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" @@ -19,14 +21,9 @@ function request(route: string, directory: string, init: RequestInit = {}) { } const Event = Schema.Struct({ - id: Schema.String, + id: EventV2.ID, type: Schema.String, - location: Schema.optional( - Schema.Struct({ - directory: Schema.String, - project: Schema.Struct({ id: Schema.String, directory: Schema.String }), - }), - ), + location: Schema.optional(Location.Ref), data: Schema.Unknown, }) @@ -50,6 +47,17 @@ afterEach(async () => { }) describe("v2 location HttpApi", () => { + test("decodes EventV2 location refs without resolved project metadata", () => { + expect( + Schema.decodeUnknownSync(Event)({ + id: "evt_test", + type: "file.watcher.updated", + location: { directory: "/tmp/project" }, + data: {}, + }), + ).toMatchObject({ location: { directory: "/tmp/project" } }) + }) + test("returns command and skill snapshots with resolved locations", async () => { await using tmp = await tmpdir({ git: true }) @@ -79,7 +87,7 @@ describe("v2 location HttpApi", () => { expect(created.status).toBe(200) expect(await readEventType(reader, "session.created")).toMatchObject({ type: "session.created", - location: { directory: publisher.path, project: { directory: publisher.path } }, + location: { directory: publisher.path }, data: { sessionID: expect.any(String) }, }) await reader.cancel() From 9bceb8eb7da5c3741bf1d412feb9499e371a73f8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 17:54:49 +0200 Subject: [PATCH 081/112] test(opencode): synchronize websocket retry failures (#33387) --- packages/opencode/test/plugin/openai-ws.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index cdcded9da63..7a125824e0b 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -559,21 +559,28 @@ describe("plugin.openai.ws-pool", () => { }) test("retries failed websocket streams before using HTTP fallback", async () => { + const attempts: Array<(socket: WebSocket) => void> = [] await using server = await createWebSocketServer((socket) => { socket.once("message", () => { socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + attempts.shift()?.(socket) }) }) const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - idleTimeout: 20, streamRetries: 1, }) + const firstAttempt = new Promise((resolve) => attempts.push(resolve)) const first = await fetch(server.url, streamRequest()) - expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket") + const firstSocket = await firstAttempt + firstSocket.terminate() + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") + const secondAttempt = new Promise((resolve) => attempts.push(resolve)) const second = await fetch(server.url, streamRequest()) - expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket") + const secondSocket = await secondAttempt + secondSocket.terminate() + expect((await readTextError(second.text())).message).toContain("WebSocket closed before response.completed") const third = await fetch(server.url, streamRequest()) expect(await third.text()).toBe("http") From f50e4accf3860a0297c70a1833fdc02782deb0e1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 17:55:04 +0200 Subject: [PATCH 082/112] test(opencode): synchronize shell cancellation (#33386) --- packages/opencode/test/session/prompt.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5cd97f78e88..9c22e004102 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1711,11 +1711,17 @@ unixNoLLMServer( withSh(() => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() + const { directory: dir } = yield* TestInstance + const afs = yield* FSUtil.Service + const ready = path.join(dir, ".shell-ready") const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .shell({ sessionID: chat.id, agent: "build", command: ": > '.shell-ready'; sleep 30" }) .pipe(Effect.forkChild) - yield* waitForBusy(chat.id) + yield* pollWithTimeout( + afs.existsSafe(ready).pipe(Effect.map((exists) => (exists ? (true as const) : undefined))), + "shell never created readiness marker", + ) yield* prompt.cancel(chat.id) From fe840d42b859f6439be8374774cdddbb6f93468b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 18:16:30 +0200 Subject: [PATCH 083/112] refactor(core): simplify session run coordination (#33388) --- packages/core/src/session.ts | 38 +- packages/core/src/session/event.ts | 8 - packages/core/src/session/execution.ts | 8 +- packages/core/src/session/execution/local.ts | 15 +- packages/core/src/session/logging.ts | 8 - packages/core/src/session/message-updater.ts | 1 - packages/core/src/session/projector.ts | 1 - packages/core/src/session/run-coordinator.ts | 289 +---- packages/core/src/session/runner/index.ts | 2 +- packages/core/src/session/runner/llm.ts | 6 +- packages/core/test/session-logging.test.ts | 30 - packages/core/test/session-prompt.test.ts | 36 +- .../core/test/session-run-coordinator.test.ts | 998 +++--------------- .../core/test/session-runner-recorded.test.ts | 25 +- packages/core/test/session-runner.test.ts | 31 +- packages/sdk/js/src/v2/gen/types.gen.ts | 53 - packages/sdk/openapi.json | 168 --- specs/v2/schema-changelog.md | 5 + specs/v2/session.md | 12 +- specs/v2/todo.md | 7 +- 20 files changed, 287 insertions(+), 1454 deletions(-) delete mode 100644 packages/core/src/session/logging.ts delete mode 100644 packages/core/test/session-logging.test.ts diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 7454e0fa752..5dae699733f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" @@ -25,7 +25,6 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" -import { logFailure } from "./session/logging" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" @@ -168,20 +167,6 @@ export const layer = Layer.effect( const store = yield* SessionStore.Service const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) - const scope = yield* Effect.scope - - const enqueueWake = (admitted: SessionInput.Admitted) => - execution.wake(admitted.sessionID, admitted.admittedSeq).pipe( - Effect.tapCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : logFailure("Failed to wake Session", admitted.sessionID, cause), - ), - Effect.ignore, - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ) - const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( Effect.mapError( @@ -342,10 +327,6 @@ export const layer = Layer.effect( Effect.uninterruptible( Effect.gen(function* () { yield* result.get(input.sessionID) - const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) { - if (input.resume !== false) yield* enqueueWake(admitted) - return admitted - }, Effect.uninterruptible) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } @@ -363,7 +344,8 @@ export const layer = Layer.effect( ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - return yield* returnPrompt(admitted) + if (input.resume !== false) yield* execution.wake(admitted.sessionID) + return admitted }), ), ), @@ -404,19 +386,7 @@ export const layer = Layer.effect( yield* execution.resume(sessionID) }), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => - Effect.uninterruptible( - Effect.gen(function* () { - const session = yield* store.get(sessionID) - if (!session) return yield* execution.interrupt(sessionID) - const event = yield* events.publish(SessionEvent.InterruptRequested, { - sessionID, - timestamp: yield* DateTime.now, - }) - if (event.durable === undefined) - return yield* Effect.die("Interrupt request event is missing aggregate sequence") - yield* execution.interrupt(sessionID, event.durable.seq) - }), - ), + Effect.uninterruptible(execution.interrupt(sessionID)), ), }) diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 5eaf0371686..97e33461762 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -118,13 +118,6 @@ export namespace PromptLifecycle { export type Promoted = typeof Promoted.Type } -export const InterruptRequested = EventV2.define({ - type: "session.next.interrupt.requested", - ...options, - schema: Base, -}) -export type InterruptRequested = typeof InterruptRequested.Type - export const ContextUpdated = EventV2.define({ type: "session.next.context.updated", ...options, @@ -475,7 +468,6 @@ const DurableDefinitions = [ Prompted, PromptLifecycle.Admitted, PromptLifecycle.Promoted, - InterruptRequested, ContextUpdated, Synthetic, Shell.Started, diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index 9a99145bfb4..a08912e9565 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index" import { SessionSchema } from "./schema" export interface Interface { - /** Explicitly drain one Session, making at least one provider attempt. */ + /** Starts execution while idle or joins the active execution. */ readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect - /** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */ - readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect + /** Registers newly recorded work. Repeated wakeups may coalesce. */ + readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect /** Interrupt active work owned by this process. Idle interruption is a no-op. */ - readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect + readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect } /** Routes execution from a Session ID to the runner owned by that Session's Location. */ diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 8f1b1763a05..7e0e3ca7003 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -1,11 +1,10 @@ -import { Effect, Layer } from "effect" +import { Cause, Effect, Layer } from "effect" import { LocationServiceMap } from "../../location-layer" import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunner } from "../runner" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionExecution } from "../execution" -import { logFailure } from "../logging" /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ export const layer = Layer.effect( @@ -13,15 +12,19 @@ export const layer = Layer.effect( Effect.gen(function* () { const store = yield* SessionStore.Service const locations = yield* LocationServiceMap - const coordinator = yield* SessionRunCoordinator.make({ - drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) { + const coordinator = yield* SessionRunCoordinator.make({ + drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) - return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe( + return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( Effect.provide(locations.get(session.location)), + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })), + ), ) }), - onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause), }) return SessionExecution.Service.of({ diff --git a/packages/core/src/session/logging.ts b/packages/core/src/session/logging.ts deleted file mode 100644 index c579ec15dcd..00000000000 --- a/packages/core/src/session/logging.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Cause, Effect } from "effect" -import { SessionSchema } from "./schema" - -export const logFailure = ( - message: "Failed to drain Session" | "Failed to wake Session", - sessionID: SessionSchema.ID, - cause: Cause.Cause, -) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID })) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index cf1eb2cedfd..2c836dcd017 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -138,7 +138,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.prompt.admitted": () => Effect.void, "session.next.prompt.promoted": () => Effect.void, - "session.next.interrupt.requested": () => Effect.void, "session.next.context.updated": (event) => adapter.appendMessage( new SessionMessage.System({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 30af9f2e6db..50bfad465a5 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -399,7 +399,6 @@ export const layer = Layer.effectDiscard( ) }), ) - yield* events.project(SessionEvent.InterruptRequested, () => Effect.void) yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index d52b63e8f11..6597842bef3 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -1,106 +1,46 @@ export * as SessionRunCoordinator from "./run-coordinator" -import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect" -import { SessionRunner } from "./runner" -import { logFailure } from "./logging" -import { SessionSchema } from "./schema" +import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" -export type Mode = "run" | "wake" - -/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */ -type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number } - -/** - * Runs at most one drain chain per key while allowing different keys to drain concurrently. - * - * For each key: - * - * idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle - * - * `run` is an explicit drain request. It starts a chain or joins the current chain and - * upgrades a pending follow-up so the caller receives explicit-run semantics. - * - * `wake` reports that durable work may now be available. It starts a chain while idle or - * requests one coalesced follow-up while draining. Repeated wakes collapse together. - * - * `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt - * boundary are suppressed; advisory wakes after the boundary run after cleanup. - */ -export interface Coordinator { - /** Starts or joins one explicit drain generation. */ - readonly run: (key: Key) => Effect.Effect - /** Coalesces one wake-up after durable work is recorded. */ - readonly wake: (key: Key, seq?: number) => Effect.Effect - /** Waits until the current ownership chain settles. */ - readonly awaitIdle: (key: Key) => Effect.Effect - /** Interrupts the active ownership chain without automatically draining pending wakes. */ - readonly interrupt: (key: Key, seq?: number) => Effect.Effect +/** Serializes execution for each key while allowing different keys to run concurrently. */ +export interface Coordinator { + /** Starts execution while idle or joins the active execution. */ + readonly run: (key: Key) => Effect.Effect + /** Registers one coalesced follow-up after newly recorded work. */ + readonly wake: (key: Key) => Effect.Effect + /** Stops active execution and waits for its cleanup. */ + readonly interrupt: (key: Key) => Effect.Effect } -/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */ -type Entry = { - readonly done: Deferred.Deferred - readonly settled: Deferred.Deferred> - current: Demand - pending?: Demand - explicitWaiter?: Deferred.Deferred - interruptSeq?: number +type Entry = { + readonly done: Deferred.Deferred owner?: Fiber.Fiber + pendingWake: boolean stopping: boolean } -/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */ -const coalesce = (left: Demand | undefined, right: Demand): Demand => { - if (left?._tag === "run" || right._tag === "run") return { _tag: "run" } - return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) } -} - -const maxSeq = (left: number | undefined, right: number | undefined) => { - if (left === undefined) return right - if (right === undefined) return left - return Math.max(left, right) -} - -/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */ -export const make = (options: { - readonly drain: (key: Key, mode: Mode) => Effect.Effect - readonly onFailure?: (key: Key, cause: Cause.Cause) => Effect.Effect -}): Effect.Effect, never, Scope.Scope> => +export const make = (options: { + readonly drain: (key: Key, force: boolean) => Effect.Effect +}): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const active = new Map>() - const interruptSeq = new Map() - const report = yield* FiberSet.makeRuntime() + const active = new Map>() const fork = yield* FiberSet.makeRuntime() - const shutdown = Deferred.makeUnsafe() - let closed = false - yield* Effect.addFinalizer(() => - Effect.sync(() => { - closed = true - Deferred.doneUnsafe(shutdown, Effect.void) - active.clear() - interruptSeq.clear() - }), - ) - const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred): Entry => ({ - done: Deferred.makeUnsafe(), - settled: Deferred.makeUnsafe>(), - current, - explicitWaiter, + const makeEntry = (): Entry => ({ + done: Deferred.makeUnsafe(), + pendingWake: false, stopping: false, }) - const start = (key: Key, entry: Entry, demand: Demand, successor = false) => { + const start = (key: Key, entry: Entry, force: boolean, successor = false) => { const ready = Deferred.makeUnsafe() - const drain = Effect.suspend(() => options.drain(key, demand._tag)) - // Initial work retains immediate-start behavior but cannot run before ownership is published. - // Observer-started successors yield once so synchronous drains cannot recurse on the JS stack. const owner = fork( (successor - ? Effect.yieldNow.pipe(Effect.andThen(drain)) - : Deferred.await(ready).pipe(Effect.andThen(drain)) + ? Effect.yieldNow + : Deferred.await(ready) ).pipe( - Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))), + Effect.andThen(Effect.suspend(() => options.drain(key, force))), + Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))), Effect.exit, Effect.asVoid, ), @@ -109,176 +49,57 @@ export const make = (options: { if (!successor) Deferred.doneUnsafe(ready, Effect.void) } - const settle = (key: Key, entry: Entry, demand: Demand, exit: Exit.Exit) => { - if (closed) { - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - return - } - if (demand._tag === "run" && entry.explicitWaiter !== undefined) { - Deferred.doneUnsafe(entry.explicitWaiter, exit) - entry.explicitWaiter = undefined - } - if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) { - Deferred.doneUnsafe(entry.explicitWaiter, exit) - entry.explicitWaiter = undefined - } - if (active.get(key) !== entry) { - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - return - } - if (exit._tag === "Success" && !entry.stopping) { - if (entry.pending !== undefined) { - const pending = entry.pending - entry.pending = undefined - entry.current = pending - start(key, entry, pending, true) - return - } - active.delete(key) - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) + const settle = (key: Key, entry: Entry, exit: Exit.Exit) => { + if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) { + entry.pendingWake = false + start(key, entry, false, true) return } - const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined + const successor = entry.pendingWake ? makeEntry() : undefined if (successor === undefined) active.delete(key) - else active.set(key, successor) - if (successor !== undefined) start(key, successor, successor.current, true) - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - if ( - exit._tag === "Failure" && - !(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) && - demand._tag === "wake" && - options.onFailure !== undefined - ) { - report(Effect.suspend(() => options.onFailure!(key, exit.cause))) + else { + active.set(key, successor) + start(key, successor, false, true) } + Deferred.doneUnsafe(entry.done, exit) } - const wake = (key: Key, seq?: number) => - Effect.sync(() => { - if (closed) return - if (!isAfterInterrupt(key, seq)) return + const run = (key: Key): Effect.Effect => + Effect.uninterruptibleMask((restore) => { const entry = active.get(key) if (entry !== undefined) { - if (!acceptsWake(entry, seq)) return - entry.pending = coalesce(entry.pending, { _tag: "wake", seq }) + if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key)))) + return restore(Deferred.await(entry.done)) + } + + const next = makeEntry() + active.set(key, next) + start(key, next, true) + return restore(Deferred.await(next.done)) + }) + + const wake = (key: Key) => + Effect.sync(() => { + const entry = active.get(key) + if (entry !== undefined) { + entry.pendingWake = true return } - const next = makeEntry({ _tag: "wake", seq }) + const next = makeEntry() active.set(key, next) - start(key, next, next.current) + start(key, next, false) }) - const awaitIdle = (key: Key): Effect.Effect => - Effect.gen(function* () { - let firstFailure: Cause.Cause | undefined - while (!closed) { - const entry = active.get(key) - if (entry === undefined) break - const exit = yield* Effect.raceFirst( - Deferred.await(entry.settled), - Deferred.await(shutdown).pipe(Effect.as(Exit.void)), - ) - if (closed) break - if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause - } - if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure) - }) - - const interrupt = (key: Key, seq?: number): Effect.Effect => + const interrupt = (key: Key): Effect.Effect => Effect.suspend(() => { const entry = active.get(key) - const latest = interruptSeq.get(key) - if (seq !== undefined && latest !== undefined && seq <= latest) - return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void - if (seq !== undefined) interruptSeq.set(key, seq) if (entry?.owner === undefined) return Effect.void - if ( - seq !== undefined && - entry.current._tag === "wake" && - entry.current.seq !== undefined && - entry.current.seq > seq - ) - return Effect.void - if (entry.stopping) { - entry.interruptSeq = maxSeq(entry.interruptSeq, seq) - suppressPendingAtOrBefore(entry, seq) - return Fiber.interrupt(entry.owner) - } entry.stopping = true - entry.interruptSeq = seq - suppressPendingAtOrBefore(entry, seq) + entry.pendingWake = false return Fiber.interrupt(entry.owner) }) - return { run, wake, awaitIdle, interrupt } - - function run(key: Key): Effect.Effect { - return Effect.uninterruptibleMask((restore) => { - if (closed) return Effect.interrupt - const entry = active.get(key) - if (entry !== undefined) { - if (entry.stopping) { - return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key)))) - } - if (entry.current._tag === "wake") { - entry.pending = coalesce(entry.pending, { _tag: "run" }) - entry.explicitWaiter ??= Deferred.makeUnsafe() - return restore(awaitRun(entry.explicitWaiter)) - } - return restore(awaitRun(entry.done)) - } - - const next = makeEntry({ _tag: "run" }) - active.set(key, next) - start(key, next, next.current) - return restore(awaitRun(next.done)) - }) - } - - function awaitRun(done: Deferred.Deferred): Effect.Effect { - return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt))) - } - - function acceptsWake(entry: Entry, seq: number | undefined) { - return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq) - } - - function isAfterInterrupt(key: Key, seq: number | undefined) { - const latest = interruptSeq.get(key) - return latest === undefined || (seq !== undefined && seq > latest) - } - - function suppressPendingAtOrBefore(entry: Entry, seq: number | undefined) { - if ( - entry.pending?._tag === "wake" && - seq !== undefined && - entry.pending.seq !== undefined && - entry.pending.seq > seq - ) - return - entry.pending = undefined - } + return { run, wake, interrupt } }) - -export interface Interface extends Coordinator {} - -export class Service extends Context.Service()("@opencode/v2/SessionRunCoordinator") {} - -export const layer = Layer.effect( - Service, - SessionRunner.Service.pipe( - Effect.flatMap((runner) => - make({ - drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }), - onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause), - }), - ), - Effect.map(Service.of), - ), -) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 2210b57b34e..634075dd91b 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -21,7 +21,7 @@ export interface Interface { /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ readonly run: (input: { readonly sessionID: SessionSchema.ID - readonly force?: boolean + readonly force: boolean }) => Effect.Effect } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 756a119280c..ddd2bf4e152 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -346,14 +346,14 @@ export const layer = Layer.effect( const run = Effect.fn("SessionRunner.run")(function* (input: { readonly sessionID: SessionSchema.ID - readonly force?: boolean + readonly force: boolean }) { const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") - if (input.force !== true && !hasSteer && !hasQueue) return + if (!input.force && !hasSteer && !hasQueue) return yield* failInterruptedTools(input.sessionID) let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined - let openActivity = input.force === true || hasSteer || hasQueue + let openActivity = input.force || hasSteer || hasQueue while (openActivity) { let needsContinuation = true for (let step = 1; needsContinuation; step++) { diff --git a/packages/core/test/session-logging.test.ts b/packages/core/test/session-logging.test.ts deleted file mode 100644 index 3d6cff2e4e0..00000000000 --- a/packages/core/test/session-logging.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Cause, Effect, Logger } from "effect" -import { logFailure } from "@opencode-ai/core/session/logging" -import { SessionSchema } from "@opencode-ai/core/session/schema" - -describe("Session logging", () => { - for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) { - test(`renders the cause for ${message}`, async () => { - const entries: Array> = [] - const logger = Logger.formatStructured.pipe( - Logger.map((entry): void => { - entries.push(entry) - }), - ) - - await logFailure( - message, - SessionSchema.ID.make("session-123"), - Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }), - ).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise) - - expect(entries).toHaveLength(1) - expect(entries[0]?.message).toBe(message) - expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" }) - expect(entries[0]?.cause).toContain("SessionFailure") - expect(entries[0]?.cause).toContain("nested-code") - expect(entries[0]?.cause).not.toContain("[Object") - }) - } -}) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index c84a3ab304e..166b5deed12 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -20,9 +20,7 @@ import { testEffect } from "./lib/effect" const executionCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = [] -const interruptSeqs: Array = [] const wakeCalls: SessionV2.ID[] = [] -const wakeSeqs: Array = [] const execution = Layer.succeed( SessionExecution.Service, SessionExecution.Service.of({ @@ -30,15 +28,13 @@ const execution = Layer.succeed( Effect.sync(() => { executionCalls.push(sessionID) }), - interrupt: (sessionID, seq) => + interrupt: (sessionID) => Effect.sync(() => { interruptCalls.push(sessionID) - interruptSeqs.push(seq) }), - wake: (sessionID, seq) => + wake: (sessionID) => Effect.sync(() => { wakeCalls.push(sessionID) - wakeSeqs.push(seq) }), }), ) @@ -109,15 +105,6 @@ const eventCount = (type: string) => ), ) -const interruptEvent = Database.Service.use(({ db }) => - db - .select() - .from(EventTable) - .where(eq(EventTable.type, "session.next.interrupt.requested.1")) - .get() - .pipe(Effect.orDie), -) - describe("SessionV2.prompt", () => { it.effect("delegates execution continuation through SessionExecution", () => Effect.gen(function* () { @@ -131,19 +118,14 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("delegates interruption through SessionExecution", () => + it.effect("delegates process-local interruption through SessionExecution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service interruptCalls.length = 0 - interruptSeqs.length = 0 yield* session.interrupt(sessionID) expect(interruptCalls).toEqual([sessionID]) - expect(interruptSeqs).toHaveLength(1) - expect(typeof interruptSeqs[0]).toBe("number") - expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(1) - expect(yield* interruptEvent).toMatchObject({ aggregate_id: sessionID, seq: interruptSeqs[0] }) expect(yield* session.messages({ sessionID })).toEqual([]) }), ) @@ -152,11 +134,9 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { const session = yield* SessionV2.Service interruptCalls.length = 0 - interruptSeqs.length = 0 yield* session.interrupt(SessionV2.ID.make("ses_missing")) expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")]) - expect(interruptSeqs).toEqual([undefined]) }), ) @@ -515,13 +495,11 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 - const admitted = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) - expect(wakeSeqs).toEqual([admitted.admittedSeq]) }), ) @@ -531,9 +509,8 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 - const admitted = yield* session.prompt({ + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run explicitly" }), resume: true, @@ -541,7 +518,6 @@ describe("SessionV2.prompt", () => { expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) - expect(wakeSeqs).toEqual([admitted.admittedSeq]) }), ) @@ -551,13 +527,11 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([]) - expect(wakeSeqs).toEqual([]) }), ) }) diff --git a/packages/core/test/session-run-coordinator.test.ts b/packages/core/test/session-run-coordinator.test.ts index 39fb5779d3a..909ba648702 100644 --- a/packages/core/test/session-run-coordinator.test.ts +++ b/packages/core/test/session-run-coordinator.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { testEffect } from "./lib/effect" @@ -22,14 +22,39 @@ describe("SessionRunCoordinator", () => { expect(runs).toBe(1) yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) expect(runs).toBe(1) }), ), ) - it.effect("starts a drain when woken while idle", () => + it.effect("joins a wake-started execution without forcing a successor", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make() + const gate = yield* Deferred.make() + const forces: boolean[] = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: (_key, force) => + Effect.sync(() => forces.push(force)).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Deferred.await(gate)), + ), + }) + + yield* coordinator.wake("session") + yield* Deferred.await(started) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Effect.yieldNow + yield* Deferred.succeed(gate, undefined) + yield* Fiber.join(resumed) + + expect(forces).toEqual([false]) + }), + ), + ) + + it.effect("starts execution when woken while idle", () => Effect.scoped( Effect.gen(function* () { const drained = yield* Deferred.make() @@ -41,62 +66,11 @@ describe("SessionRunCoordinator", () => { ), ) - it.effect("does nothing when interrupted while idle", () => - Effect.scoped( - Effect.gen(function* () { - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) - - yield* coordinator.interrupt("session") - }), - ), - ) - - it.effect("suppresses stale wakes after an idle interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => runs++) }) - - yield* coordinator.interrupt("session", 2) - yield* coordinator.wake("session", 1) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(0) - - yield* coordinator.wake("session", 3) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(1) - }), - ), - ) - - it.effect("does not interrupt a wake newer than the interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const gate = yield* Deferred.make() - const interrupted = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(gate)), - Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), - ), - }) - - yield* coordinator.wake("session", 3) - yield* Deferred.await(started) - yield* coordinator.interrupt("session", 2) - expect(yield* Deferred.isDone(interrupted)).toBeFalse() - yield* Deferred.succeed(gate, undefined) - yield* coordinator.awaitIdle("session") - }), - ), - ) - - it.effect("preserves a queued wake newer than the interrupt boundary", () => + it.effect("coalesces wakes received during active execution", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() + const firstGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() let runs = 0 const coordinator = yield* SessionRunCoordinator.make({ @@ -104,569 +78,33 @@ describe("SessionRunCoordinator", () => { Effect.sync(() => ++runs).pipe( Effect.flatMap((run) => run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) + ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(firstGate))) : Deferred.succeed(secondStarted, undefined), ), ), }) - yield* coordinator.wake("session", 1) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Deferred.await(firstStarted) - yield* coordinator.wake("session", 3) - yield* coordinator.interrupt("session", 2) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts only the requested key", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const secondGate = yield* Deferred.make() - const secondInterrupted = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: (key: string) => - key === "first" - ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Deferred.succeed(secondStarted, undefined).pipe( - Effect.andThen(Deferred.await(secondGate)), - Effect.onInterrupt(() => Deferred.succeed(secondInterrupted, undefined)), - ), - }) - - yield* coordinator.wake("first") - yield* coordinator.wake("second") - yield* Effect.all([Deferred.await(firstStarted), Deferred.await(secondStarted)]) - - yield* coordinator.interrupt("first") - expect(yield* Deferred.isDone(secondInterrupted)).toBeFalse() - yield* Deferred.succeed(secondGate, undefined) - yield* coordinator.awaitIdle("second") - }), - ), - ) - - it.effect("interrupts the active drain and suppresses its queued wake", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const interrupted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), - ) - : Effect.void, - ), - ), - }) - - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.await(firstStarted) - yield* coordinator.wake("session") - - yield* coordinator.interrupt("session") - yield* Deferred.await(interrupted) - yield* coordinator.awaitIdle("session") - const exit = yield* Fiber.await(run) - expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() - expect(runs).toBe(1) - yield* coordinator.interrupt("session") - }), - ), - ) - - it.effect("suppresses a wake received during interruption cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* coordinator.wake("session", 1) - yield* Deferred.await(firstInterrupted) - expect(runs).toBe(1) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* coordinator.awaitIdle("session") - - expect(runs).toBe(1) - yield* coordinator.wake("session", 3) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(2) - }), - ), - ) - - it.effect("remembers a wake received after the interrupt boundary during cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Deferred.await(firstInterrupted) - yield* coordinator.wake("session", 3) - const staleInterrupt = yield* coordinator.interrupt("session", 1).pipe(Effect.forkChild) - expect(runs).toBe(1) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* Fiber.join(staleInterrupt) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("moves the stop barrier forward for repeated interrupts", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const firstInterrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Deferred.await(firstInterrupted) - yield* coordinator.wake("session", 3) - const secondInterrupt = yield* coordinator.interrupt("session", 4).pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(firstInterrupt) - yield* Fiber.join(secondInterrupt) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(1) - - yield* coordinator.wake("session", 5) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts an explicit run queued before the interruption request", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void, - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - - yield* coordinator.interrupt("session") - const exit = yield* Fiber.await(run) - expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() - expect(runs).toBe(1) - }), - ), - ) - - it.effect("settles a pre-interrupt explicit run only after active wake cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const runSettled = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - const run = yield* coordinator - .run("session") - .pipe(Effect.exit, Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - - expect(yield* Deferred.isDone(runSettled)).toBeFalse() - yield* Deferred.succeed(cleanupGate, undefined) - const runExit = yield* Fiber.join(run) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - }), - ), - ) - - it.effect("starts an explicit run arriving during interrupt cleanup after the stop barrier", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* Fiber.join(run) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts pre-stop waiters and runs post-stop waiters after cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const before = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const after = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - - const beforeExit = yield* Fiber.join(before) - expect(Exit.isFailure(beforeExit) && Cause.hasInterruptsOnly(beforeExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - yield* Fiber.join(after) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("waits for interrupt cleanup before settling callers", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const runSettled = yield* Deferred.make() - const idleSettled = yield* Deferred.make() - const interruptSettled = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - const run = yield* coordinator - .run("session") - .pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild) - yield* Deferred.await(started) - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.exit, Effect.ensuring(Deferred.succeed(idleSettled, undefined)), Effect.forkChild) - const interrupt = yield* coordinator - .interrupt("session") - .pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild) - yield* Deferred.await(cleanupStarted) - - expect(yield* Deferred.isDone(runSettled)).toBeFalse() - expect(yield* Deferred.isDone(idleSettled)).toBeFalse() - expect(yield* Deferred.isDone(interruptSettled)).toBeFalse() - yield* Deferred.succeed(cleanupGate, undefined) - const runExit = yield* Fiber.await(run) - const idleExit = yield* Fiber.join(idle) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(Exit.isFailure(idleExit) && Cause.hasInterruptsOnly(idleExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - }), - ), - ) - - it.effect("joins concurrent interruption requests for one active drain", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - const first = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const second = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - - yield* Fiber.join(first) - yield* Fiber.join(second) - }), - ), - ) - - it.effect("does not discard a post-stop explicit run when interrupted again", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const firstInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - const secondInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - - yield* Effect.all([Fiber.join(firstInterrupt), Fiber.join(secondInterrupt), Fiber.join(run)]) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("coalesces wakes received during an active run", () => - Effect.scoped( - Effect.gen(function* () { - const gate = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe(Effect.flatMap((run) => (run === 1 ? Deferred.await(gate) : Effect.void))), - }) - - const first = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow yield* Effect.all([coordinator.wake("session"), coordinator.wake("session"), coordinator.wake("session")], { concurrency: "unbounded", }) - yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("waits for a coalesced ownership chain to become idle", () => - Effect.scoped( - Effect.gen(function* () { - const firstGate = yield* Deferred.make() - const secondGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const idleSettled = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.await(firstGate) - : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))), - ), - ), - }) - - yield* coordinator.wake("session") - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.andThen(Deferred.succeed(idleSettled, undefined)), Effect.forkChild) - yield* coordinator.wake("session") yield* Deferred.succeed(firstGate, undefined) yield* Deferred.await(secondStarted) - expect(yield* Deferred.isDone(idleSettled)).toBeFalse() - yield* Deferred.succeed(secondGate, undefined) - yield* Fiber.join(idle) + yield* Fiber.join(resumed) expect(runs).toBe(2) }), ), ) - it.effect("reports the first defect after a failed chain becomes idle", () => - Effect.scoped( - Effect.gen(function* () { - const firstGate = yield* Deferred.make() - const secondGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const defect = new Error("defect") - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.await(firstGate).pipe(Effect.andThen(Effect.die(defect))) - : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))), - ), - ), - }) - - yield* coordinator.wake("session") - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.catchDefect(Effect.succeed), Effect.forkChild({ startImmediately: true })) - yield* coordinator.wake("session") - yield* Deferred.succeed(firstGate, undefined) - yield* Deferred.await(secondStarted) - yield* Deferred.succeed(secondGate, undefined) - - expect(yield* Fiber.join(idle)).toBe(defect) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("runs again when woken during the coalesced drain", () => + it.effect("runs again when woken during the follow-up", () => Effect.scoped( Effect.gen(function* () { const firstGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() const secondGate = yield* Deferred.make() + const thirdStarted = yield* Deferred.make() let runs = 0 const coordinator = yield* SessionRunCoordinator.make({ drain: () => @@ -676,196 +114,168 @@ describe("SessionRunCoordinator", () => { ? Deferred.await(firstGate) : run === 2 ? Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))) - : Effect.void, + : Deferred.succeed(thirdStarted, undefined), ), ), }) - const first = yield* coordinator.run("session").pipe(Effect.forkChild) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Effect.yieldNow yield* coordinator.wake("session") yield* Deferred.succeed(firstGate, undefined) yield* Deferred.await(secondStarted) yield* coordinator.wake("session") yield* Deferred.succeed(secondGate, undefined) - yield* Fiber.join(first) + yield* Deferred.await(thirdStarted) + yield* Fiber.join(resumed) expect(runs).toBe(3) }), ), ) - it.effect("starts one successor after a wake races with failure", () => + it.effect("does nothing when interrupted while idle", () => + Effect.scoped( + Effect.gen(function* () { + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) + yield* coordinator.interrupt("session") + }), + ), + ) + + it.effect("interrupts active execution and clears its pending wake", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make() + const interrupted = yield* Deferred.make() + let runs = 0 + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => + Effect.sync(() => ++runs).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ), + }) + + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* coordinator.wake("session") + yield* coordinator.interrupt("session") + yield* Deferred.await(interrupted) + + const exit = yield* Fiber.await(resumed) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() + expect(runs).toBe(1) + }), + ), + ) + + it.effect("runs a wake registered during interruption cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const cleanupStarted = yield* Deferred.make() + const cleanupGate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + let runs = 0 + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => + Effect.sync(() => ++runs).pipe( + Effect.flatMap((run) => + run === 1 + ? Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), + ), + ) + : Deferred.succeed(secondStarted, undefined), + ), + ), + }) + + yield* coordinator.wake("session") + yield* Deferred.await(firstStarted) + const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) + yield* Deferred.await(cleanupStarted) + yield* coordinator.wake("session") + yield* Deferred.succeed(cleanupGate, undefined) + yield* Fiber.join(interrupt) + yield* Deferred.await(secondStarted) + + expect(runs).toBe(2) + }), + ), + ) + + it.effect("starts a resume registered during interruption cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const cleanupStarted = yield* Deferred.make() + const cleanupGate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const forces: boolean[] = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: (_key, force) => { + forces.push(force) + return forces.length === 1 + ? Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), + ), + ) + : Deferred.succeed(secondStarted, undefined) + }, + }) + + yield* coordinator.wake("session") + yield* Deferred.await(firstStarted) + const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) + yield* Deferred.await(cleanupStarted) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Deferred.succeed(cleanupGate, undefined) + yield* Effect.all([Fiber.join(interrupt), Fiber.join(resumed)]) + yield* Deferred.await(secondStarted) + + expect(forces).toEqual([false, true]) + }), + ), + ) + + it.effect("starts one follow-up when a wake races with failure", () => Effect.scoped( Effect.gen(function* () { const gate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() const failure = new Error("failed") let runs = 0 const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( Effect.flatMap((run) => - run === 1 ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) : Effect.void, + run === 1 + ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) + : Deferred.succeed(secondStarted, undefined), ), ), }) - const first = yield* coordinator.run("session").pipe(Effect.forkChild) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Effect.yieldNow yield* coordinator.wake("session") yield* Deferred.succeed(gate, undefined) - expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure) - yield* Effect.yieldNow + expect(yield* Fiber.join(resumed).pipe(Effect.flip)).toBe(failure) + yield* Deferred.await(secondStarted) expect(runs).toBe(2) }), ), ) - it.effect("upgrades an active wake when an explicit run joins it", () => - Effect.scoped( - Effect.gen(function* () { - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.sync(() => modes.push(mode)).pipe( - Effect.andThen( - mode === "wake" - ? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - : Effect.void, - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(wakeStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Fiber.join(run) - - expect(modes).toEqual(["wake", "run"]) - }), - ), - ) - - it.effect("upgrades a recursive wake drain when an explicit run joins it", () => - Effect.scoped( - Effect.gen(function* () { - const runGate = yield* Deferred.make() - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const forcedStarted = yield* Deferred.make() - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.gen(function* () { - modes.push(mode) - if (modes.length === 1) return yield* Deferred.await(runGate) - if (modes.length === 2) - return yield* Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - yield* Deferred.succeed(forcedStarted, undefined) - }), - }) - - const first = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* coordinator.wake("session") - yield* Deferred.succeed(runGate, undefined) - yield* Deferred.await(wakeStarted) - const second = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Deferred.await(forcedStarted) - yield* Fiber.join(first) - yield* Fiber.join(second) - - expect(modes).toEqual(["run", "wake", "run"]) - }), - ), - ) - - it.effect("propagates an upgraded explicit run failure before a successful advisory successor", () => - Effect.scoped( - Effect.gen(function* () { - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const runStarted = yield* Deferred.make() - const runGate = yield* Deferred.make() - const advisoryStarted = yield* Deferred.make() - const failure = new Error("explicit run failed") - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.sync(() => modes.push(mode)).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - : run === 2 - ? Deferred.succeed(runStarted, undefined).pipe( - Effect.andThen(Deferred.await(runGate)), - Effect.andThen(Effect.fail(failure)), - ) - : Deferred.succeed(advisoryStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(wakeStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Deferred.await(runStarted) - yield* coordinator.wake("session") - yield* Deferred.succeed(runGate, undefined) - yield* Deferred.await(advisoryStarted) - - expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) - expect(modes).toEqual(["wake", "run", "wake"]) - }), - ), - ) - - it.effect("settles active callers when its owning scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make() - const started = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - }).pipe(Scope.provide(scope)) - - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.await(started) - const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* Scope.close(scope, Exit.void) - - const runExit = yield* Fiber.await(run) - const idleExit = yield* Fiber.await(idle) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(Exit.isSuccess(idleExit)).toBeTrue() - }), - ) - - it.effect("does not start work after its owning scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.sync(() => runs++), - }).pipe(Scope.provide(scope)) - yield* Scope.close(scope, Exit.void) - - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session") - const runExit = yield* coordinator.run("session").pipe(Effect.exit) - - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(runs).toBe(0) - }), - ) - - it.effect("does not cancel the owner when one joined waiter is interrupted", () => + it.effect("does not cancel execution when a joined waiter is interrupted", () => Effect.scoped( Effect.gen(function* () { const gate = yield* Deferred.make() @@ -904,105 +314,29 @@ describe("SessionRunCoordinator", () => { const second = yield* coordinator.run("second").pipe(Effect.forkChild) yield* Deferred.await(bothStarted) yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) }), ), ) - it.effect("reports an advisory drain failure exactly once", () => - Effect.scoped( - Effect.gen(function* () { - const failure = new Error("wake failed") - const reported: Cause.Cause[] = [] - const reportedOnce = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.fail(failure), - onFailure: (_key, cause) => - Effect.sync(() => reported.push(cause)).pipe(Effect.andThen(Deferred.succeed(reportedOnce, undefined))), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(reportedOnce) - yield* Effect.yieldNow - - expect(reported).toHaveLength(1) - expect(Cause.squash(reported[0]!)).toBe(failure) - }), - ), - ) - - it.effect("contains defects thrown while constructing an advisory failure report", () => - Effect.scoped( - Effect.gen(function* () { - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.fail(new Error("wake failed")), - onFailure: () => { - throw new Error("report defect") - }, - }) - - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - }), - ), - ) - - it.effect("reports an independently interrupted advisory drain", () => - Effect.scoped( - Effect.gen(function* () { - const reported = yield* Deferred.make>() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.interrupt, - onFailure: (_key, cause) => Deferred.succeed(reported, cause).pipe(Effect.asVoid), - }) - - yield* coordinator.wake("session") - - expect(Cause.hasInterruptsOnly(yield* Deferred.await(reported))).toBeTrue() - }), - ), - ) - - it.effect("does not report deliberate interruption as an advisory failure", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const reported: Cause.Cause[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - onFailure: (_key, cause) => Effect.sync(() => reported.push(cause)), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - yield* coordinator.interrupt("session") - yield* Effect.yieldNow - - expect(reported).toEqual([]) - }), - ), - ) - - it.effect("trampolines many synchronous self-waking drains", () => + it.effect("trampolines synchronous self-waking execution", () => Effect.scoped( Effect.gen(function* () { const limit = 20_000 + const completed = yield* Deferred.make() let runs = 0 let wake: (key: string) => Effect.Effect = () => Effect.void - const coordinator = yield* SessionRunCoordinator.make({ + const coordinator = yield* SessionRunCoordinator.make({ drain: (key) => Effect.sync(() => ++runs).pipe( - Effect.tap((run) => (run < limit ? wake(key) : Effect.void)), + Effect.tap((run) => (run < limit ? wake(key) : Deferred.succeed(completed, undefined))), Effect.asVoid, ), }) wake = coordinator.wake yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session") + yield* Deferred.await(completed) expect(runs).toBe(limit) }), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 91d7a24475d..65e90cb6d09 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -16,6 +16,7 @@ import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" +import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" @@ -83,19 +84,20 @@ const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(referenceGuidance), Layer.provide(config), ) -const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( SessionExecution.Service, - SessionRunCoordinator.Service.pipe( - Effect.map((coordinator) => - SessionExecution.Service.of({ - resume: coordinator.run, - wake: coordinator.wake, - interrupt: coordinator.interrupt, - }), - ), - ), -).pipe(Layer.provide(coordinator)) + Effect.gen(function* () { + const sessionRunner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + }) + return SessionExecution.Service.of({ + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + }) + }), +).pipe(Layer.provide(runner)) const sessions = SessionV2.layer.pipe( Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), @@ -120,7 +122,6 @@ const it = testEffect( skillGuidance, config, runner, - coordinator, execution, sessions, ), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index f37a4c357a1..6e97ab7939f 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -244,19 +244,20 @@ const runner = SessionRunnerLLM.layer.pipe( Layer.provide(referenceGuidance), Layer.provide(config), ) -const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( SessionExecution.Service, - SessionRunCoordinator.Service.pipe( - Effect.map((coordinator) => - SessionExecution.Service.of({ - resume: coordinator.run, - wake: coordinator.wake, - interrupt: coordinator.interrupt, - }), - ), - ), -).pipe(Layer.provide(coordinator)) + Effect.gen(function* () { + const sessionRunner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + }) + return SessionExecution.Service.of({ + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + }) + }), +).pipe(Layer.provide(runner)) const sessions = SessionV2.layer.pipe( Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), @@ -283,7 +284,6 @@ const it = testEffect( skillGuidance, config, runner, - coordinator, execution, sessions, ), @@ -681,7 +681,6 @@ describe("SessionRunnerLLM", () => { systemUnavailable = false yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) }) - yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID) expect(requests).toHaveLength(1) expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"]) @@ -2161,7 +2160,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"]) - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow expect(requests).toHaveLength(2) }), @@ -2367,7 +2366,7 @@ describe("SessionRunnerLLM", () => { }) requests.length = 0 - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow expect(requests).toHaveLength(1) @@ -2417,7 +2416,7 @@ describe("SessionRunnerLLM", () => { LLMEvent.finish({ reason: "stop" }), ] - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) while (requests.length === 0) yield* Effect.yieldNow expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"]) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b2900e8d618..e27957f88fe 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -21,7 +21,6 @@ export type Event = | EventSessionNextPrompted | EventSessionNextPromptAdmitted | EventSessionNextPromptPromoted - | EventSessionNextInterruptRequested | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextShellStarted @@ -876,14 +875,6 @@ export type GlobalEvent = { timeCreated: number } } - | { - id: string - type: "session.next.interrupt.requested" - properties: { - timestamp: number - sessionID: string - } - } | { id: string type: "session.next.context.updated" @@ -1638,7 +1629,6 @@ export type GlobalEvent = { | SyncEventSessionNextPrompted | SyncEventSessionNextPromptAdmitted | SyncEventSessionNextPromptPromoted - | SyncEventSessionNextInterruptRequested | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted @@ -2781,7 +2771,6 @@ export type V2Event = | V2EventSessionNextPrompted | V2EventSessionNextPromptAdmitted | V2EventSessionNextPromptPromoted - | V2EventSessionNextInterruptRequested | V2EventSessionNextContextUpdated | V2EventSessionNextSynthetic | V2EventSessionNextShellStarted @@ -3249,21 +3238,6 @@ export type SyncEventSessionNextPromptPromoted = { } } -export type SyncEventSessionNextInterruptRequested = { - type: "sync" - id: string - syncEvent: { - type: "session.next.interrupt.requested.1" - id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - } - } -} - export type SyncEventSessionNextContextUpdated = { type: "sync" id: string @@ -4570,24 +4544,6 @@ export type V2EventSessionNextPromptPromoted = { } } -export type V2EventSessionNextInterruptRequested = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.interrupt.requested" - data: { - timestamp: number - sessionID: string - } -} - export type V2EventSessionNextContextUpdated = { id: string metadata?: { @@ -6224,15 +6180,6 @@ export type EventSessionNextPromptPromoted = { } } -export type EventSessionNextInterruptRequested = { - id: string - type: "session.next.interrupt.requested" - properties: { - timestamp: number - sessionID: string - } -} - export type EventSessionNextContextUpdated = { id: string type: "session.next.context.updated" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d9aee60510d..7501fc4b907 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14689,9 +14689,6 @@ { "$ref": "#/components/schemas/EventSessionNextPromptPromoted" }, - { - "$ref": "#/components/schemas/EventSessionNextInterruptRequested" - }, { "$ref": "#/components/schemas/EventSessionNextContextUpdated" }, @@ -17297,35 +17294,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.interrupt.requested"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, { "type": "object", "properties": { @@ -19867,9 +19835,6 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextInterruptRequested" - }, { "$ref": "#/components/schemas/SyncEventSessionNextContextUpdated" }, @@ -23111,9 +23076,6 @@ { "$ref": "#/components/schemas/V2EventSessionNextPromptPromoted" }, - { - "$ref": "#/components/schemas/V2EventSessionNextInterruptRequested" - }, { "$ref": "#/components/schemas/V2EventSessionNextContextUpdated" }, @@ -24498,56 +24460,6 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, - "SyncEventSessionNextInterruptRequested": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "syncEvent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["session.next.interrupt.requested.1"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["type", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - } - }, - "required": ["type", "id", "syncEvent"], - "additionalProperties": false - }, "SyncEventSessionNextContextUpdated": { "type": "object", "properties": { @@ -28771,57 +28683,6 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionNextInterruptRequested": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.interrupt.requested"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, "V2EventSessionNextContextUpdated": { "type": "object", "properties": { @@ -33460,35 +33321,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextInterruptRequested": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.interrupt.requested"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSessionNextContextUpdated": { "type": "object", "properties": { diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index a9a08b50139..f32948c82b8 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,10 @@ # V2 Schema Changelog +## 2026-06-22: Make Session Interruption Process-Local + +- Remove the unprojected `session.next.interrupt.requested.1` event from the experimental durable Session event union and generated SDK. +- No canonical V1 data requires migration; experimental V2 event history containing the retired event is disposable. + ## 2026-06-05: Execute Automatic Session Compaction - Trigger automatic compaction before provider turns using the complete estimated request and absolute model-aware headroom. diff --git a/specs/v2/session.md b/specs/v2/session.md index 43c93b17517..ea22e6008e9 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -20,10 +20,10 @@ sessions.prompt({ id?, sessionID, prompt, delivery?, resume? }) -> resume false admits only sessions.interrupt(sessionID) - -> interrupts the active ownership chain on this process - -> waits for active drain cleanup and settlement - -> suppresses reruns already queued before interruption - -> preserves durable inbox rows for a later fresh wake or resume + -> interrupts active execution on this process + -> waits for runner cleanup and settlement + -> clears a coalesced follow-up wake already registered with this coordinator + -> preserves durable inbox rows for a later wake or resume -> idle or missing Session is a no-op ``` @@ -152,12 +152,12 @@ Inbox delivery is explicit: Execution has two entry points: -- `run` is an explicit resume. It joins an active drain chain or starts one, and performs at least one provider attempt even when no input is eligible. +- `run` is an explicit resume. It joins any active execution or starts a forced drain while idle. A forced drain bypasses the no-eligible-input guard, but preparation may still fail before a provider attempt. - `wake` reports newly recorded durable inbox work. Repeated wakes coalesce. A wake calls the provider only when it can promote eligible input. Post-crash activity recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model durable activity identity, provider-dispatch ambiguity, required continuation, queue-opener reservation, retry policy, and visible recovery status together. -A process-global `SessionRunCoordinator` serializes each local Session drain chain while allowing different Sessions to drain concurrently. It enters the Session's current Location only when a drain starts, so interruption targets process execution ownership rather than Location cache identity. Interruption establishes a local ownership-chain boundary by stopping the current chain while preserving pending/unpromoted durable inbox rows for a later fresh wake and projected history for explicit resume. A Location runner also fences every new provider turn against its captured Location so a moved Session cannot begin another turn through source-Location tools or context. An already-dispatched provider turn may still settle source-Location calls until a future move-control slice interrupts active ownership. Automatic startup discovery, durable multi-node ownership, stale-owner fencing, and retry policy remain future work. +A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location. Inbox promotion coalesces pending steers in durable admission order and opens one queued activity at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 5d77cbea3d7..893b1dc2dd3 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -33,12 +33,7 @@ through legacy `SessionPrompt.loop(...)`: Prompt admission now uses a durable `session_input` inbox rather than immediate transcript projection. `steer` inputs coalesce into the active activity at the next safe provider-turn boundary. `queue` inputs form a FIFO of future activities -that open one at a time. A process-global `SessionRunCoordinator` coalesces process-local wakeups -around settlement races. Explicit `run` resumes perform at least one provider -attempt; advisory `wake` notifications call the provider only for eligible inbox -work. Steers coalesce into the active activity at -safe provider boundaries; queued inputs open later activities one at a time in -FIFO order. +that open one at a time. Next reviewed slices: From 494123a8754d4d6fc28563c8a380a6ff9f39bbb0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 22 Jun 2026 16:18:36 +0000 Subject: [PATCH 084/112] chore: generate --- packages/core/src/session/run-coordinator.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index 6597842bef3..cfab42300ef 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -35,10 +35,7 @@ export const make = (options: { const start = (key: Key, entry: Entry, force: boolean, successor = false) => { const ready = Deferred.makeUnsafe() const owner = fork( - (successor - ? Effect.yieldNow - : Deferred.await(ready) - ).pipe( + (successor ? Effect.yieldNow : Deferred.await(ready)).pipe( Effect.andThen(Effect.suspend(() => options.drain(key, force))), Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))), Effect.exit, From 39d7394ede7cb5a729f2296480f58d9f57038c9f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:39:52 -0500 Subject: [PATCH 085/112] fix(stats): hide unique users tooltip total --- packages/stats/app/src/routes/index.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 8e4b86a00af..51ca059f8e8 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -744,10 +744,14 @@ function TopModelsChart(props: { data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"} > {point().date} - - {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} - -
+ + + {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} + + + +
+ {(item) => (

Date: Mon, 22 Jun 2026 11:47:02 -0500 Subject: [PATCH 086/112] fix: dont show gpt-5.5-pro when using codex oauth (#33400) Co-authored-by: Devin Oldenburg --- packages/opencode/src/plugin/openai/codex.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 93c22ea6af3..c13a9c439d4 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -13,6 +13,7 @@ const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) +const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) interface PkceCodes { verifier: string @@ -370,6 +371,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug Object.entries(provider.models) .filter(([, model]) => { if (ALLOWED_MODELS.has(model.api.id)) return true + if (DISALLOWED_MODELS.has(model.api.id)) return false const match = model.api.id.match(/^gpt-(\d+\.\d+)/) return match ? parseFloat(match[1]) > 5.4 : false }) From 130957288e13b5f9ef26b9d7dec131aa2113fa74 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 19:43:07 +0200 Subject: [PATCH 087/112] fix(llm): preserve structured tool errors (#33405) --- packages/llm/src/protocols/shared.ts | 11 +++- .../llm/test/provider/openai-chat.test.ts | 21 +++++++ .../test/provider/openai-responses.test.ts | 58 +++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 66b353c8285..c5b6003fd28 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -19,6 +19,7 @@ export { isRecord } export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) export const encodeJson = Schema.encodeSync(Json) +const isJson = Schema.is(Schema.Json) export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) @@ -243,8 +244,14 @@ export const validateToolFile = (route: string, part: ToolFileContent, supported export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") export const toolResultText = (part: ToolResultPart) => { - if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) - if (part.result.type === "content") return encodeJson(part.result.value) + if (part.result.type === "text") return String(part.result.value) + if (part.result.type === "error") { + const value = part.result.value + const prototype = + typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) + const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null + return structured && isJson(value) ? encodeJson(value) : String(value) + } return encodeJson(part.result.value) } diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9966b92e3de..5dbc89f1aee 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -224,6 +224,27 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { error: { type: "unknown", message: "Tool execution interrupted" } } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }), + ], + }), + ) + + expect(prepared.body.messages.at(-1)).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: ProviderShared.encodeJson(error), + }) + }), + ) + it.effect("continues image tool results as vision input without base64 text", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 717a7e8024f..b854537fe20 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -360,6 +360,64 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { + error: { type: "unknown", message: "Tool execution interrupted" }, + content: [], + structured: {}, + } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]), + Message.tool({ + id: "call_1", + name: "bash", + resultType: "error", + result: error, + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error)) + }), + ) + + it.effect("keeps primitive tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("503") + }), + ) + + it.effect("keeps non-JSON tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("Error: boom") + }), + ) + // Regression: screenshot/read tool results must stay structured so base64 // image data is not JSON-stringified into `function_call_output.output`. it.effect("lowers image tool-result content as structured input_image items", () => From 1787fa4261960947a04bfe9367b61dc7f5b2b7ce Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 21:39:41 +0200 Subject: [PATCH 088/112] refactor(core): drop legacy compaction event (#33404) --- packages/core/src/database/migration.gen.ts | 1 + .../20260622170816_reset_v2_session_state.ts | 17 ++++ packages/core/src/session/event.ts | 13 +-- packages/core/src/session/projector.ts | 4 +- packages/core/test/database-migration.test.ts | 92 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- specs/v2/schema-changelog.md | 8 +- specs/v2/session.md | 2 +- 9 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index fd778414aa9..19b1b568432 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -38,5 +38,6 @@ export const migrations = ( import("./migration/20260611192811_lush_chimera"), import("./migration/20260612174303_project_dir_strategy"), import("./migration/20260622142730_simplify_session_context_epoch"), + import("./migration/20260622170816_reset_v2_session_state"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts new file mode 100644 index 00000000000..b771a64bb74 --- /dev/null +++ b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260622170816_reset_v2_session_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_context_epoch\`;`) + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`) + yield* tx.run(`DELETE FROM \`workspace\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 97e33461762..d4b773d18bf 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -436,20 +436,9 @@ export namespace Compaction { }) export type Delta = typeof Delta.Type - // Retain the unpublished v1 decoder so stored beta events remain replayable. - export const EndedV1 = EventV2.define({ - type: "session.next.compaction.ended", - ...options, - schema: { - ...Base, - text: Schema.String, - include: Schema.String.pipe(Schema.optional), - }, - }) - export const Ended = EventV2.define({ type: "session.next.compaction.ended", - durable: { aggregate: "sessionID", version: 2 }, + ...options, schema: { ...Base, messageID: SessionMessageID.ID, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 50bfad465a5..2c87dfb8dff 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -417,9 +417,7 @@ export const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) - yield* events.project(SessionEvent.Compaction.Ended, (event) => - event.durable?.version === 1 ? Effect.void : run(db, event), - ) + yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) }), ) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 914243a5899..835f41931d6 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -14,6 +14,8 @@ import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/m import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" +import resetV2SessionStateMigration from "@opencode-ai/core/database/migration/20260622170816_reset_v2_session_state" +import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -22,6 +24,8 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionV1 } from "@opencode-ai/core/v1/session" import { tmpdir } from "./fixture/tmpdir" const run = (effect: Effect.Effect) => @@ -226,6 +230,94 @@ describe("DatabaseMigration", () => { ) }) + test("preserves canonical V1 state and restarts its event stream", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`PRAGMA foreign_keys = ON`) + yield* DatabaseMigration.apply(db) + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/project', 1, 1, '[]')`, + ) + yield* db.run( + sql`INSERT INTO workspace (id, type, project_id, time_used) VALUES ('workspace', 'local', 'global', 1)`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'global', 'workspace', 'session', '/project', 'Before', 'test', 1, 1)`, + ) + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('message', 'session', 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('part', 'message', 'session', 1, 1, '{}')`, + ) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`, + ) + yield* db.run(sql`DELETE FROM migration WHERE id = ${resetV2SessionStateMigration.id}`) + yield* DatabaseMigration.applyOnly(db, [resetV2SessionStateMigration]) + + const database = Layer.succeed(Database.Service, { db }) + const events = EventV2.layer.pipe(Layer.provide(database)) + yield* EventV2.Service.use((service) => + service.publish(SessionV1.Event.Updated, { + sessionID: SessionSchema.ID.make("session"), + info: { + id: SessionSchema.ID.make("session"), + slug: "session", + projectID: ProjectV2.ID.global, + directory: "/project", + title: "After", + version: "test", + time: { created: 1, updated: 2 }, + }, + }), + ).pipe( + Effect.provide( + Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))), + ), + ) + + expect( + yield* db.get(sql` + SELECT + (SELECT title FROM session WHERE id = 'session') AS title, + (SELECT workspace_id FROM session WHERE id = 'session') AS workspaceID, + (SELECT COUNT(*) FROM message WHERE id = 'message') AS messages, + (SELECT COUNT(*) FROM part WHERE id = 'part') AS parts, + (SELECT COUNT(*) FROM workspace) AS workspaces, + (SELECT COUNT(*) FROM session_input) AS sessionInputs, + (SELECT COUNT(*) FROM session_message) AS sessionMessages, + (SELECT COUNT(*) FROM session_context_epoch) AS contextEpochs, + (SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq, + (SELECT type FROM event WHERE aggregate_id = 'session') AS eventType + `), + ).toEqual({ + title: "After", + workspaceID: null, + messages: 1, + parts: 1, + workspaces: 0, + sessionInputs: 0, + sessionMessages: 0, + contextEpochs: 0, + seq: 0, + eventType: "session.updated.1", + }) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e27957f88fe..b15ff934709 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3637,7 +3637,7 @@ export type SyncEventSessionNextCompactionEnded = { type: "sync" id: string syncEvent: { - type: "session.next.compaction.ended.2" + type: "session.next.compaction.ended.1" id: string seq: number aggregateID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7501fc4b907..0d8dc1aecfe 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -25744,7 +25744,7 @@ "properties": { "type": { "type": "string", - "enum": ["session.next.compaction.ended.2"] + "enum": ["session.next.compaction.ended.1"] }, "id": { "type": "string", diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index f32948c82b8..6d9c0efddbc 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,11 @@ # V2 Schema Changelog +## 2026-06-22: Reset Unpublished Compaction Event + +- Replace the unpublished `session.next.compaction.ended.1` payload with the current checkpoint payload and remove its legacy decoder. +- Reset experimental events, sequences, Session inputs, projected Session messages, Context Epochs, synchronized workspace rows, and Session workspace links. +- Preserve canonical V1 `session`, `message`, and `part` rows. + ## 2026-06-22: Make Session Interruption Process-Local - Remove the unprojected `session.next.interrupt.requested.1` event from the experimental durable Session event union and generated SDK. @@ -11,7 +17,7 @@ - Preserve the existing structured summary contract and update prior summaries with newly compacted history. - Store token-bounded recent history as plain serialized text inside the checkpoint instead of replaying provider-native messages. - Keep compaction starts durable and progress deltas live-only; activate history cutover only from a durable completed summary. -- Version the completed event as `session.next.compaction.ended.2` rather than changing the existing synchronized v1 payload in place. +- Store the completed event with the current checkpoint payload containing stable message identity, reason, summary, and recent context. - Reload the replacement Context Epoch and continue the original pending turn after compaction. - Preserve full durable history; compaction changes only the active model representation. - Defer provider-overflow recovery, explicit manual compaction, and deterministic old tool-result pruning. diff --git a/specs/v2/session.md b/specs/v2/session.md index ea22e6008e9..9946758322e 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -107,7 +107,7 @@ Before each provider turn, the runner estimates the complete model-visible reque Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes. -`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.2` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active. +`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active. Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn. From a0a500316ee009b5f84f593ec6b304e737224ad7 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 22 Jun 2026 15:33:56 -0400 Subject: [PATCH 089/112] zen: new inference --- packages/console/app/src/routes/zen/util/handler.ts | 7 +++++++ packages/console/core/src/model.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index e34c3e750b8..46a0f17fcd7 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -196,6 +196,13 @@ export async function handler( Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => { headers.set(k, headers.get(v)!) }) + Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => { + if (v === "$ip") return headers.set(k, ip) + if (v === "$session") return headers.set(k, sessionId) + if (v === "$model") return headers.set(k, model) + if (v === "$request") return headers.set(k, requestId) + headers.set(k, v) + }) headers.delete("host") headers.delete("content-length") headers.delete("x-opencode-request") diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 4355c18818a..bd18d44503a 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -53,6 +53,7 @@ export namespace ZenData { apiKey: z.union([z.string(), z.record(z.string(), z.string())]), format: FormatSchema.optional(), headerMappings: z.record(z.string(), z.string()).optional(), + headerModifier: z.record(z.string(), z.any()).optional(), payloadModifier: z.record(z.string(), z.any()).optional(), payloadMappings: z.record(z.string(), z.string()).optional(), adjustCacheUsage: z.boolean().optional(), From 34b3d59a23eb979d1d66a8f2ede1dae6a33c277a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:52:43 -0500 Subject: [PATCH 090/112] ignore: update agents.md (#33446) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 02b1c4cb772..1557a7f5601 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,7 @@ const table = sqliteTable("session", { ## Testing -- Avoid mocks as much as possible +- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. - Test actual implementation, do not duplicate logic into tests - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. From f48f24ec4e1e26cc32c4d4953497fe2734c61ee1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 23:51:49 +0200 Subject: [PATCH 091/112] refactor(core): simplify session input promotion (#33443) --- packages/core/src/database/migration.gen.ts | 1 + .../20260622202450_simplify_session_input.ts | 17 ++ packages/core/src/event.ts | 13 ++ packages/core/src/session/context-epoch.ts | 4 +- packages/core/src/session/event.ts | 47 ++-- packages/core/src/session/input.ts | 111 ++++------ packages/core/src/session/message-updater.ts | 1 - packages/core/src/session/projector.ts | 33 +-- packages/core/src/session/runner/llm.ts | 2 +- packages/core/test/database-migration.test.ts | 6 +- packages/core/test/session-create.test.ts | 6 +- packages/core/test/session-projector.test.ts | 11 +- packages/core/test/session-prompt.test.ts | 35 ++- .../core/test/session-runner-recorded.test.ts | 2 +- packages/core/test/session-runner.test.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 65 ------ packages/sdk/openapi.json | 208 ------------------ packages/tui/src/context/data.tsx | 12 - packages/tui/test/cli/tui/data.test.tsx | 58 +---- specs/v2/schema-changelog.md | 7 + specs/v2/session.md | 8 +- 21 files changed, 160 insertions(+), 493 deletions(-) create mode 100644 packages/core/src/database/migration/20260622202450_simplify_session_input.ts diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 19b1b568432..e6ea4eaa147 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -39,5 +39,6 @@ export const migrations = ( import("./migration/20260612174303_project_dir_strategy"), import("./migration/20260622142730_simplify_session_context_epoch"), import("./migration/20260622170816_reset_v2_session_state"), + import("./migration/20260622202450_simplify_session_input"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260622202450_simplify_session_input.ts b/packages/core/src/database/migration/20260622202450_simplify_session_input.ts new file mode 100644 index 00000000000..0b5ddd1bfde --- /dev/null +++ b/packages/core/src/database/migration/20260622202450_simplify_session_input.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260622202450_simplify_session_input", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_context_epoch\`;`) + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`) + yield* tx.run(`DELETE FROM \`workspace\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 32aaeae6995..3439a0aefc8 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -46,6 +46,19 @@ export type Payload = { export type Subscriber = (event: Payload) => Effect.Effect export type Unsubscribe = Effect.Effect +export const latestSequence = Effect.fn("EventV2.latestSequence")(function* ( + db: Database.Interface["db"], + aggregateID: string, +) { + const row = yield* db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + return row?.seq ?? -1 +}) + export type SerializedEvent = { readonly id: ID readonly type: string diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 18624706a97..f06b69cd515 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -64,7 +64,7 @@ const prepareOnce = Effect.fnUntraced(function* ( return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } } if (result._tag === "ReplacementReady") { - const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID)) + const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID)) yield* replace(db, sessionID, baselineSeq, result.generation) return { baseline: result.generation.baseline, baselineSeq } } @@ -124,7 +124,7 @@ const insert = Effect.fnUntraced(function* ( sessionID: SessionSchema.ID, generation: SystemContext.Generation, ) { - const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) + const baselineSeq = yield* EventV2.latestSequence(db, sessionID) yield* db .insert(SessionContextEpochTable) .values({ diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index d4b773d18bf..88ac4aa2679 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -25,6 +25,12 @@ const Base = { timestamp: V2Schema.DateTimeUtcFromMillis, sessionID: SessionSchema.ID, } +const PromptFields = { + ...Base, + messageID: SessionMessageID.ID, + prompt: Prompt, + delivery: Schema.Literals(["steer", "queue"]), +} const options = { durable: { @@ -83,40 +89,16 @@ export type Moved = typeof Moved.Type export const Prompted = EventV2.define({ type: "session.next.prompted", ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Schema.Literals(["steer", "queue"]), - }, + schema: PromptFields, }) export type Prompted = typeof Prompted.Type -export namespace PromptLifecycle { - export const Admitted = EventV2.define({ - type: "session.next.prompt.admitted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Schema.Literals(["steer", "queue"]), - }, - }) - export type Admitted = typeof Admitted.Type - - export const Promoted = EventV2.define({ - type: "session.next.prompt.promoted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - timeCreated: V2Schema.DateTimeUtcFromMillis, - }, - }) - export type Promoted = typeof Promoted.Type -} +export const PromptAdmitted = EventV2.define({ + type: "session.next.prompt.admitted", + ...options, + schema: PromptFields, +}) +export type PromptAdmitted = typeof PromptAdmitted.Type export const ContextUpdated = EventV2.define({ type: "session.next.context.updated", @@ -455,8 +437,7 @@ const DurableDefinitions = [ ModelSwitched, Moved, Prompted, - PromptLifecycle.Admitted, - PromptLifecycle.Promoted, + PromptAdmitted, ContextUpdated, Synthetic, Shell.Started, diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index f8bc2b0e6bb..a45a3710037 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -4,7 +4,6 @@ import { and, asc, eq, isNull, lte } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" import type { Database } from "../database/database" import type { EventV2 } from "../event" -import { EventSequenceTable } from "../event/sql" import { NonNegativeInt } from "../schema" import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" @@ -65,7 +64,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( if (existing !== undefined) return existing const timestamp = yield* DateTime.now return yield* events - .publish(SessionEvent.PromptLifecycle.Admitted, { + .publish(SessionEvent.PromptAdmitted, { messageID: input.id, sessionID: input.sessionID, timestamp, @@ -93,19 +92,6 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( ) }) -export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, -) { - const row = yield* db - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, sessionID)) - .get() - .pipe(Effect.orDie) - return row?.seq ?? -1 -}) - export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( db: DatabaseService, input: { @@ -117,6 +103,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio readonly timeCreated: DateTime.Utc }, ) { + const message = yield* db + .select({ id: SessionMessageTable.id }) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, input.id)) + .get() + .pipe(Effect.orDie) + if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id })) const stored = yield* db .insert(SessionInputTable) .values({ @@ -134,12 +127,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* ( +export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID readonly prompt: Prompt + readonly delivery: Delivery readonly timeCreated: DateTime.Utc readonly promotedSeq: number }, @@ -157,14 +151,32 @@ export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(functio .returning() .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id })) - const stored = fromRow(updated) - if ( - !matchesPrompt(stored, input) || - DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated) - ) - return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return toMessage(stored) + if (updated) { + const stored = fromRow(updated) + if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return + } + + const stored = yield* find(db, input.id) + if (stored) { + if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq) + return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return + } + + yield* db + .insert(SessionInputTable) + .values({ + id: input.id, + session_id: input.sessionID, + prompt: encodePrompt(input.prompt), + delivery: input.delivery, + admitted_seq: input.promotedSeq, + promoted_seq: input.promotedSeq, + time_created: DateTime.toEpochMillis(input.timeCreated), + }) + .run() + .pipe(Effect.orDie) }) export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( @@ -201,35 +213,17 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) -export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* ( - db: DatabaseService, - input: { - readonly id: SessionMessage.ID +const matchesProjection = ( + input: Admitted, + expected: { readonly sessionID: SessionSchema.ID readonly prompt: Prompt readonly delivery: Delivery readonly timeCreated: DateTime.Utc - readonly promotedSeq: number }, -) { - const inserted = yield* db - .insert(SessionInputTable) - .values({ - id: input.id, - session_id: input.sessionID, - admitted_seq: input.promotedSeq, - prompt: encodePrompt(input.prompt), - delivery: input.delivery, - promoted_seq: input.promotedSeq, - time_created: DateTime.toEpochMillis(input.timeCreated), - }) - .onConflictDoNothing() - .returning() - .get() - .pipe(Effect.orDie) - if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input") - return fromRow(inserted) -}) +) => + equivalent(input, expected) && + DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated) const publish = Effect.fn("SessionInput.publish")(function* ( db: DatabaseService, @@ -238,18 +232,19 @@ const publish = Effect.fn("SessionInput.publish")(function* ( rows: ReadonlyArray, ) { for (const row of rows) { + const id = SessionMessage.ID.make(row.id) yield* events - .publish(SessionEvent.PromptLifecycle.Promoted, { + .publish(SessionEvent.Prompted, { sessionID, - timestamp: yield* DateTime.now, - messageID: SessionMessage.ID.make(row.id), + timestamp: DateTime.makeUnsafe(row.time_created), + messageID: id, prompt: decodePrompt(row.prompt), - timeCreated: DateTime.makeUnsafe(row.time_created), + delivery: row.delivery, }) .pipe( Effect.catchDefect((defect) => defect instanceof LifecycleConflict - ? find(db, SessionMessage.ID.make(row.id)).pipe( + ? find(db, id).pipe( Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), ) : Effect.die(defect), @@ -303,13 +298,3 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun .pipe(Effect.orDie) return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true)) }) - -const toMessage = (input: Admitted) => - new SessionMessage.User({ - id: input.id, - type: "user", - text: input.prompt.text, - files: input.prompt.files, - agents: input.prompt.agents, - time: { created: input.timeCreated }, - }) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 2c836dcd017..4ece3c2d195 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -137,7 +137,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }, "session.next.prompt.admitted": () => Effect.void, - "session.next.prompt.promoted": () => Effect.void, "session.next.context.updated": (event) => adapter.appendMessage( new SessionMessage.System({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 2c87dfb8dff..4a0512d4759 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -21,7 +21,6 @@ type DatabaseService = Database.Interface["db"] const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) -class PromptAlreadyProjected extends Error {} export class SessionAlreadyProjected extends Error {} type Usage = { @@ -350,27 +349,19 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { - const messageID = event.data.messageID - const existing = yield* db - .select({ id: SessionMessageTable.id }) - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, messageID)) - .get() - .pipe(Effect.orDie) - if (existing) return yield* Effect.die(new PromptAlreadyProjected()) - yield* run(db, event) if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") - yield* SessionInput.projectLegacyPrompted(db, { - id: messageID, + yield* SessionInput.projectPrompted(db, { + id: event.data.messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, timeCreated: event.data.timestamp, promotedSeq: event.durable.seq, }) + yield* run(db, event) }), ) - yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => + yield* events.project(SessionEvent.PromptAdmitted, (event) => Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionInput.projectAdmitted(db, { @@ -383,22 +374,6 @@ export const layer = Layer.effectDiscard( }) }), ) - yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => - Effect.gen(function* () { - if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") - yield* insertMessage( - db, - event, - yield* SessionInput.projectPromoted(db, { - id: event.data.messageID, - sessionID: event.data.sessionID, - prompt: event.data.prompt, - timeCreated: event.data.timeCreated, - promotedSeq: event.durable.seq, - }), - ) - }), - ) yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index ddd2bf4e152..9caae384947 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -176,7 +176,7 @@ export const layer = Layer.effect( const toolFibers = yield* FiberSet.make() let needsContinuation = false if (promotion) { - const cutoff = yield* SessionInput.latestSeq(db, session.id) + const cutoff = yield* EventV2.latestSequence(db, session.id) if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff) if (promotion === "queue") { yield* SessionInput.promoteNextQueued(db, events, session.id) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 835f41931d6..63768719355 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -14,7 +14,7 @@ import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/m import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" -import resetV2SessionStateMigration from "@opencode-ai/core/database/migration/20260622170816_reset_v2_session_state" +import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -264,8 +264,8 @@ describe("DatabaseMigration", () => { yield* db.run( sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`, ) - yield* db.run(sql`DELETE FROM migration WHERE id = ${resetV2SessionStateMigration.id}`) - yield* DatabaseMigration.applyOnly(db, [resetV2SessionStateMigration]) + yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`) + yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration]) const database = Layer.succeed(Database.Service, { db }) const events = EventV2.layer.pipe(Layer.provide(database)) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 6fd80c60da1..1ae6c011fc4 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -225,7 +225,7 @@ describe("SessionV2.create", () => { Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ { durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } }, - { durable: { seq: 2 }, type: "session.next.prompt.promoted" }, + { durable: { seq: 2 }, type: "session.next.prompted" }, ]) }), ) @@ -308,8 +308,8 @@ describe("SessionV2.create", () => { .pipe(Effect.orDie)).map((event) => [event.seq, event.type]), ).toEqual([ [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], - [1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)], - [2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)], + [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], + [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)], ]) }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore)))) }), diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index a0894d07eb0..76c3290a707 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -120,7 +120,7 @@ describe("SessionProjector", () => { ), ) - it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () => + it.effect("marks an inbox row promoted with the Prompted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -142,19 +142,20 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_admitted") - yield* SessionInput.admit(db, events, { + const admitted = yield* SessionInput.admit(db, events, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer", }) + if (!admitted) return yield* Effect.die("Prompt admission failed") - const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, { + const event = yield* events.publish(SessionEvent.Prompted, { sessionID, - timestamp: created, + timestamp: admitted.timeCreated, messageID: id, prompt: new Prompt({ text: "promote me" }), - timeCreated: created, + delivery: "steer", }) expect( diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 166b5deed12..842474396e2 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -179,8 +179,8 @@ describe("SessionV2.prompt", () => { expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([ [0, "session.next.prompt.admitted"], [1, "session.next.prompt.admitted"], - [2, "session.next.prompt.promoted"], - [3, "session.next.prompt.promoted"], + [2, "session.next.prompted"], + [3, "session.next.prompted"], ]) expect( Array.from( @@ -334,7 +334,7 @@ describe("SessionV2.prompt", () => { expect(messages[1]).toEqual(messages[0]) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admittedCount).toBe(1) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1) }), ) @@ -354,7 +354,7 @@ describe("SessionV2.prompt", () => { { concurrency: "unbounded" }, ) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Promote once" }, @@ -362,14 +362,14 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("promotes steers only through the captured aggregate cutoff", () => + it.effect("promotes steers only through the captured inbox cutoff", () => Effect.gen(function* () { yield* setup const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false }) - const cutoff = yield* SessionInput.latestSeq(db, sessionID) + const cutoff = first.admittedSeq const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, cutoff) @@ -379,7 +379,7 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("reprojects one pending lifecycle without scheduling execution", () => + it.effect("reprojects pending inbox input without scheduling execution", () => Effect.gen(function* () { yield* setup const { db } = yield* Database.Service @@ -489,6 +489,27 @@ describe("SessionV2.prompt", () => { }), ) + it.effect("rejects a prompt ID already used by visible Session history", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + text: "Existing history", + }) + + const failure = yield* session + .prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Conflicting prompt" }), resume: false }) + .pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) + expect(yield* admitted(messageID)).toBeUndefined() + }), + ) + it.effect("starts execution by default after recording the prompt", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 65e90cb6d09..331c5bb48c1 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -176,7 +176,7 @@ describe("SessionRunnerLLM recorded", () => { .all()).map((event) => event.type), ).toEqual([ "session.next.prompt.admitted.1", - "session.next.prompt.promoted.1", + "session.next.prompted.1", "session.next.step.started.1", "session.next.text.started.1", "session.next.text.ended.1", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6e97ab7939f..ec932c904bc 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2404,7 +2404,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) @@ -2429,9 +2429,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service yield* events.listen((event) => - event.type === SessionEvent.PromptLifecycle.Promoted.type - ? Effect.die("fail after prompt promotion commits") - : Effect.void, + event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) yield* session.prompt({ sessionID, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b15ff934709..f71b010420d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -20,7 +20,6 @@ export type Event = | EventSessionNextMoved | EventSessionNextPrompted | EventSessionNextPromptAdmitted - | EventSessionNextPromptPromoted | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextShellStarted @@ -864,17 +863,6 @@ export type GlobalEvent = { delivery: "steer" | "queue" } } - | { - id: string - type: "session.next.prompt.promoted" - properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } - } | { id: string type: "session.next.context.updated" @@ -1628,7 +1616,6 @@ export type GlobalEvent = { | SyncEventSessionNextMoved | SyncEventSessionNextPrompted | SyncEventSessionNextPromptAdmitted - | SyncEventSessionNextPromptPromoted | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted @@ -2770,7 +2757,6 @@ export type V2Event = | V2EventSessionNextMoved | V2EventSessionNextPrompted | V2EventSessionNextPromptAdmitted - | V2EventSessionNextPromptPromoted | V2EventSessionNextContextUpdated | V2EventSessionNextSynthetic | V2EventSessionNextShellStarted @@ -3220,24 +3206,6 @@ export type SyncEventSessionNextPromptAdmitted = { } } -export type SyncEventSessionNextPromptPromoted = { - type: "sync" - id: string - syncEvent: { - type: "session.next.prompt.promoted.1" - id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } - } -} - export type SyncEventSessionNextContextUpdated = { type: "sync" id: string @@ -4523,27 +4491,6 @@ export type V2EventSessionNextPromptAdmitted = { } } -export type V2EventSessionNextPromptPromoted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.prompt.promoted" - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } -} - export type V2EventSessionNextContextUpdated = { id: string metadata?: { @@ -6168,18 +6115,6 @@ export type EventSessionNextPromptAdmitted = { } } -export type EventSessionNextPromptPromoted = { - id: string - type: "session.next.prompt.promoted" - properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } -} - export type EventSessionNextContextUpdated = { id: string type: "session.next.context.updated" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0d8dc1aecfe..0f8f5ca1980 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14686,9 +14686,6 @@ { "$ref": "#/components/schemas/EventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/EventSessionNextPromptPromoted" - }, { "$ref": "#/components/schemas/EventSessionNextContextUpdated" }, @@ -17255,45 +17252,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, { "type": "object", "properties": { @@ -19832,9 +19790,6 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" - }, { "$ref": "#/components/schemas/SyncEventSessionNextContextUpdated" }, @@ -23073,9 +23028,6 @@ { "$ref": "#/components/schemas/V2EventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/V2EventSessionNextPromptPromoted" - }, { "$ref": "#/components/schemas/V2EventSessionNextContextUpdated" }, @@ -24400,66 +24352,6 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, - "SyncEventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "syncEvent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted.1"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["type", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - } - }, - "required": ["type", "id", "syncEvent"], - "additionalProperties": false - }, "SyncEventSessionNextContextUpdated": { "type": "object", "properties": { @@ -28622,67 +28514,6 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, "V2EventSessionNextContextUpdated": { "type": "object", "properties": { @@ -33282,45 +33113,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSessionNextContextUpdated": { "type": "object", "properties": { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 05cf4afbebc..9b2e58907ad 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -164,18 +164,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } case "session.next.prompt.admitted": break - case "session.next.prompt.promoted": - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { - id: event.data.messageID, - type: "user", - text: event.data.prompt.text, - files: event.data.prompt.files, - agents: event.data.prompt.agents, - time: { created: event.data.timeCreated }, - }) - }) - break case "session.next.context.updated": message.update(event.data.sessionID, (draft) => { message.prepend(draft, { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 0d6ada4d161..279ba4d065c 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -370,7 +370,7 @@ test("settles pending tools when a live failure arrives", async () => { } }) -test("renders admitted prompts only after promotion", async () => { +test("renders admitted prompts only after they become model-visible", async () => { const events = createEventSource() const calls = createFetch(undefined, events) let sync!: ReturnType @@ -413,14 +413,14 @@ test("renders admitted prompts only after promotion", async () => { expect(sync.session.message.list("session-1") ?? []).toEqual([]) emitEvent(events, { - id: "evt_promoted_1", - type: "session.next.prompt.promoted", + id: "evt_prompted_1", + type: "session.next.prompted", properties: { sessionID: "session-1", messageID: "msg_user_1", - timestamp: 1, + timestamp: 0, prompt: { text: "hello" }, - timeCreated: 0, + delivery: "steer", }, }) @@ -434,54 +434,6 @@ test("renders admitted prompts only after promotion", async () => { } }) -test("renders a promoted prompt when admission was missed", async () => { - const events = createEventSource() - const calls = createFetch(undefined, events) - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useData() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - emitEvent(events, { - id: "evt_promoted_1", - type: "session.next.prompt.promoted", - properties: { - sessionID: "session-1", - messageID: "msg_user_1", - timestamp: 1, - prompt: { text: "hello" }, - timeCreated: 0, - }, - }) - - await wait(() => sync.session.message.list("session-1")?.length === 1) - expect(sync.session.message.list("session-1")?.[0]?.id).toBe("msg_user_1") - } finally { - app.renderer.destroy() - } -}) - test("projects live context updates with their message ID", async () => { const events = createEventSource() const calls = createFetch(undefined, events) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 6d9c0efddbc..bdd48371562 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,12 @@ # V2 Schema Changelog +## 2026-06-22: Simplify Session Input Promotion + +- Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input. +- Replace `session.next.prompt.promoted.1` with the existing `session.next.prompted.1` event when input becomes model-visible. +- Preserve the prompt endpoint, admission receipt, idempotency, steer/queue ordering, and atomic user-message projection. +- Reset experimental V2 events, projections, inputs, Context Epochs, and synchronized workspace state while preserving canonical V1 `session`, `message`, and `part` rows. + ## 2026-06-22: Reset Unpublished Compaction Event - Replace the unpublished `session.next.compaction.ended.1` payload with the current checkpoint payload and remove its legacy decoder. diff --git a/specs/v2/session.md b/specs/v2/session.md index 9946758322e..6788a893eaf 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -12,8 +12,8 @@ sessions.create({ id?, location, ... }) sessions.prompt({ id?, sessionID, prompt, delivery?, resume? }) -> omitted ID generates one internal message ID - -> supplied ID admits one durable Session input when absent - -> exact reuse returns the same admitted lifecycle receipt + -> supplied ID inserts one durable Session inbox row when absent + -> exact reuse returns the same admission receipt -> reusing one message ID for another Session, prompt, or delivery mode fails -> exact retry schedules another wake unless resume is false -> resume omitted or true schedules execution after admission @@ -27,7 +27,9 @@ sessions.interrupt(sessionID) -> idle or missing Session is a no-op ``` -`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `PromptLifecycle.Promoted`. The projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. The legacy V1-to-V2 shadow bridge continues publishing ordinary `Prompted` events for already-visible V1 prompts. +`session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts. + +`admittedSeq` is the durable Session event sequence of `PromptAdmitted`. Clients may use the admission event to represent queued input before `Prompted` makes it part of visible conversation history. Execution routing starts from only the Session ID: From dc468bdcfd92b120a2e54493c63994549fa8f11a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 23 Jun 2026 01:01:14 +0200 Subject: [PATCH 092/112] fix(core): reset steps for promoted prompts (#33452) --- AGENTS.md | 4 +- CONTEXT.md | 17 +++ packages/core/src/session/runner/llm.ts | 53 ++++---- packages/core/test/session-runner.test.ts | 147 +++++++++++----------- packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 +- packages/sdk/openapi.json | 4 +- packages/server/src/groups/session.ts | 4 +- specs/v2/schema-changelog.md | 2 +- specs/v2/session.md | 12 +- specs/v2/todo.md | 19 +-- 10 files changed, 144 insertions(+), 122 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1557a7f5601..4c6be738db5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ const table = sqliteTable("session", { - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. -- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work. -- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles. +- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. +- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. diff --git a/CONTEXT.md b/CONTEXT.md index faf8ce9d125..7fe7f3fc8da 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti **Safe Provider-Turn Boundary**: The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. +**Admitted Prompt**: +A durable user input accepted into the Session inbox but not yet included in **Session History**. + +**Prompt Promotion**: +The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**. + +**Provider Turn**: +One request to a model provider and the response projected from that request. + +**Session Drain**: +One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary. + **Model Tool Output**: The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit. @@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY, - Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. - Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. - At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. +- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**. +- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message. +- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once. +- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another. +- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity. - The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. - Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion. - Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 9caae384947..2cd77beb071 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -79,7 +79,7 @@ import { MAX_STEPS_PROMPT } from "./max-steps" * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * * Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here. - * Durable activity recovery remains a separate future slice with an explicit retry policy. + * Durable continuation recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an @@ -142,9 +142,9 @@ export const layer = Layer.effect( type TurnTransition = // Automatic compaction completed; rebuild the request from compacted history. - | { readonly _tag: "ContinueAfterCompaction" } + | { readonly _tag: "ContinueAfterCompaction"; readonly step: number } // Overflow compaction completed; rebuild once through the path without overflow recovery. - | { readonly _tag: "ContinueAfterOverflowCompaction" } + | { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number } class TurnTransitionError extends Error { constructor(readonly transition: TurnTransition) { @@ -152,10 +152,9 @@ export const layer = Layer.effect( } } - const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" }) - const continueAfterOverflowCompaction = new TurnTransitionError({ - _tag: "ContinueAfterOverflowCompaction", - }) + const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step }) + const continueAfterOverflowCompaction = (step: number) => + new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) const loadSystemContext = (agent: AgentV2.Selection) => Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { @@ -175,20 +174,23 @@ export const layer = Layer.effect( const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) const toolFibers = yield* FiberSet.make() let needsContinuation = false + let currentStep = step if (promotion) { const cutoff = yield* EventV2.latestSequence(db, session.id) - if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + let promoted = 0 + if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff) if (promotion === "queue") { - yield* SessionInput.promoteNextQueued(db, events, session.id) - yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id)) + promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff) } + if (promoted > 0) currentStep = 1 } const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps + const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ @@ -202,7 +204,7 @@ export const layer = Layer.effect( toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) - return yield* Effect.die(continueAfterCompaction) + return yield* Effect.die(continueAfterCompaction(currentStep)) const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: agent.id, @@ -272,7 +274,7 @@ export const layer = Layer.effect( isContextOverflowFailure(overflowFailure ?? failure) && (yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request }))) ) - return yield* Effect.die(continueAfterOverflowCompaction) + return yield* Effect.die(continueAfterOverflowCompaction(currentStep)) if (overflowFailure) yield* publish(overflowFailure) const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { @@ -306,7 +308,7 @@ export const layer = Layer.effect( yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause) - return !publisher.hasProviderError() && needsContinuation + return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep } }), ) }, Effect.scoped) @@ -314,7 +316,7 @@ export const layer = Layer.effect( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, - ) => Effect.Effect + ) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError> const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { return yield* runTurnAttempt(sessionID, promotion, step).pipe( @@ -324,7 +326,7 @@ export const layer = Layer.effect( if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, undefined, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) }), ), ) @@ -337,8 +339,8 @@ export const layer = Layer.effect( if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") - return yield* runAfterOverflowCompaction(sessionID, undefined, step) - return yield* runTurn(sessionID, undefined, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) + return yield* runTurn(sessionID, undefined, defect.transition.step) }), ), ) @@ -353,16 +355,19 @@ export const layer = Layer.effect( if (!input.force && !hasSteer && !hasQueue) return yield* failInterruptedTools(input.sessionID) let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined - let openActivity = input.force || hasSteer || hasQueue - while (openActivity) { + let shouldRun = input.force || hasSteer || hasQueue + while (shouldRun) { let needsContinuation = true - for (let step = 1; needsContinuation; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion, step) + let step = 1 + while (needsContinuation) { + const result = yield* runTurn(input.sessionID, promotion, step) + needsContinuation = result.needsContinuation + step = result.step + 1 promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") } - openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") - promotion = openActivity ? "queue" : undefined + shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue") + promotion = shouldRun ? "queue" : undefined } }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ec932c904bc..572c599be77 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1851,7 +1851,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("starts queued input after the active activity settles", () => + it.effect("promotes queued input after continuation ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1883,7 +1883,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait until the next activity" }), + prompt: new Prompt({ text: "Wait until continuation ends" }), delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -1894,7 +1894,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(3) expect(userTexts(requests[0]!)).toEqual(["Start working"]) expect(userTexts(requests[1]!)).toEqual(["Start working"]) - expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until the next activity"]) + expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"]) }), ) @@ -1984,7 +1984,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("runs queued active inputs as separate FIFO activities", () => + it.effect("promotes queued inputs one at a time in FIFO order", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -2027,14 +2027,14 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("opens queued input after idle steering activity settles", () => + it.effect("promotes queued input after steering continuation ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering activity" }), resume: false }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering" }), resume: false }) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Queue later activity" }), + prompt: new Prompt({ text: "Queue for later" }), delivery: "queue", resume: false, }) @@ -2056,12 +2056,12 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests).toHaveLength(2) - expect(userTexts(requests[0]!)).toEqual(["Start steering activity"]) - expect(userTexts(requests[1]!)).toEqual(["Start steering activity", "Queue later activity"]) + expect(userTexts(requests[0]!)).toEqual(["Start steering"]) + expect(userTexts(requests[1]!)).toEqual(["Start steering", "Queue for later"]) }), ) - it.effect("coalesces steers into the active queued activity before starting the next queued activity", () => + it.effect("promotes steers before the next queued input", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -2101,8 +2101,8 @@ describe("SessionRunnerLLM", () => { streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer first queued activity" }) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer first queued activity" }) }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer before next queued input" }) }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2113,14 +2113,14 @@ describe("SessionRunnerLLM", () => { expect(userTexts(requests[2]!)).toEqual([ "Start working", "Queue first", - "Steer first queued activity", - "Also steer first queued activity", + "Steer before next queued input", + "Also steer before next queued input", ]) expect(userTexts(requests[3]!)).toEqual([ "Start working", "Queue first", - "Steer first queued activity", - "Also steer first queued activity", + "Steer before next queued input", + "Also steer before next queued input", "Queue second", ]) }), @@ -2354,13 +2354,13 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("starts the first queued activity when woken while idle", () => + it.effect("promotes the first queued input when woken while idle", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait for fresh activity" }), + prompt: new Prompt({ text: "Wait in queue" }), delivery: "queue", resume: false, }) @@ -2370,30 +2370,7 @@ describe("SessionRunnerLLM", () => { yield* Effect.yieldNow expect(requests).toHaveLength(1) - expect(userTexts(requests[0]!)).toEqual(["Wait for fresh activity"]) - }), - ) - - it.effect("does not spend one activity step budget across queued activities", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const queued = Array.from({ length: 26 }, (_, index) => `Queued activity ${index + 1}`) - for (const text of queued) { - yield* session.prompt({ sessionID, prompt: new Prompt({ text }), delivery: "queue", resume: false }) - } - - requests.length = 0 - responses = queued.map(() => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ]) - - yield* session.resume(sessionID) - - expect(requests).toHaveLength(queued.length) - expect(userTexts(requests.at(-1)!)).toEqual(queued) + expect(userTexts(requests[0]!)).toEqual(["Wait in queue"]) }), ) @@ -2768,7 +2745,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("interrupts a blocked provider turn without local tool activity", () => + it.effect("interrupts a blocked provider turn without local tool execution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -2828,38 +2805,6 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("continues past 25 local tool steps when the agent has no step limit", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false }) - - requests.length = 0 - authorizations.length = 0 - executions.length = 0 - streamGate = undefined - streamStarted = undefined - responses = [ - ...Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]), - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] - - yield* session.resume(sessionID) - - expect(requests).toHaveLength(26) - expect(executions).toHaveLength(25) - }), - ) - it.effect("forces a text response on an agent's configured final step", () => Effect.gen(function* () { yield* setup @@ -2908,6 +2853,58 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("resets the configured step allowance when steering input promotes", () => + Effect.gen(function* () { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.transform((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start work" }), resume: false }) + + requests.length = 0 + executions.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-before-steer", name: "echo", input: { text: "before" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-after-steer", name: "echo", input: { text: "after" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + ] + streamGate = yield* Deferred.make() + streamStarted = yield* Deferred.make() + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(streamStarted) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) }) + yield* Deferred.succeed(streamGate, undefined) + yield* Fiber.join(run) + streamGate = undefined + streamStarted = undefined + + expect(requests).toHaveLength(3) + expect(requests[1]?.toolChoice).toBeUndefined() + expect(requests[1]?.tools).not.toEqual([]) + expect(requests[2]?.toolChoice).toMatchObject({ type: "none" }) + expect(executions).toEqual(["before", "after"]) + }), + ) + it.effect("projects provider errors as terminal assistant step failures", () => Effect.gen(function* () { yield* setup diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e6bec85f99d..d501e28132c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5344,7 +5344,7 @@ export class Session3 extends HeyApiClient { /** * Switch session agent * - * Switch the agent used by subsequent session activity. + * Switch the agent used by subsequent provider turns. */ public switchAgent( parameters: { @@ -5383,7 +5383,7 @@ export class Session3 extends HeyApiClient { /** * Switch session model * - * Switch the model used by subsequent session activity. + * Switch the model used by subsequent provider turns. */ public switchModel( parameters: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0f8f5ca1980..14c8b94dd0e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10383,7 +10383,7 @@ } } }, - "description": "Switch the agent used by subsequent session activity.", + "description": "Switch the agent used by subsequent provider turns.", "summary": "Switch session agent", "requestBody": { "content": { @@ -10468,7 +10468,7 @@ } } }, - "description": "Switch the model used by subsequent session activity.", + "description": "Switch the model used by subsequent provider turns.", "summary": "Switch session model", "requestBody": { "content": { diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index ac4418d39f7..6a4f4aff609 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -152,7 +152,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") OpenApi.annotations({ identifier: "v2.session.switchAgent", summary: "Switch session agent", - description: "Switch the agent used by subsequent session activity.", + description: "Switch the agent used by subsequent provider turns.", }), ), ) @@ -168,7 +168,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") OpenApi.annotations({ identifier: "v2.session.switchModel", summary: "Switch session model", - description: "Switch the model used by subsequent session activity.", + description: "Switch the model used by subsequent provider turns.", }), ), ) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index bdd48371562..cb90e305e8b 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -138,7 +138,7 @@ Change: Reason: - Prompt admission and model-visible promotion must be separate durable operations. -- Steering must promote at safe provider-turn boundaries while queued prompts remain separate FIFO activities. +- Steering must promote at safe provider-turn boundaries while queued prompts remain pending in FIFO order until continuation would otherwise end. Compatibility: diff --git a/specs/v2/session.md b/specs/v2/session.md index 6788a893eaf..6529ff22326 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -42,7 +42,7 @@ SessionExecution.resume(sessionID) `SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. +The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured provider-turn allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. @@ -96,7 +96,7 @@ Ambient project discovery canonicalizes and contains traversal within the projec Current Context Epoch follow-ups: - Add configured, remote, and nested instruction sources with explicit precedence and removal semantics. -- Add durable post-crash activity recovery for promoted or provider-dispatched work. +- Add durable post-crash continuation recovery for promoted or provider-dispatched work. - Add explicit manual compaction on top of automatic request-budget compaction. - Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth. - Consider watcher-backed per-file caching only if measurements show direct safe-boundary observation is too expensive. @@ -113,7 +113,7 @@ Compaction keeps the full transcript durable while replacing its active model re Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn. -When a provider rejects a request as context overflow before durable assistant output or tool activity, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical provider turn with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up. +When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical provider turn with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up. ## V1 Runtime Context Parity @@ -150,18 +150,18 @@ Provider timeout, retry, and watchdog policy is intentionally deferred. The runn Inbox delivery is explicit: - `steer` inputs promote at the next safe provider-turn boundary, including continuation inside the current drain. -- `queue` inputs form a FIFO of future activities. When the current activity settles, the runner promotes exactly one queued input to open the next activity. Multiple queued inputs remain separate activities. +- `queue` inputs remain in a FIFO while the current drain requires continuation. When the Session would otherwise become idle, the runner promotes exactly one queued input, then reevaluates continuation before promoting another. Execution has two entry points: - `run` is an explicit resume. It joins any active execution or starts a forced drain while idle. A forced drain bypasses the no-eligible-input guard, but preparation may still fail before a provider attempt. - `wake` reports newly recorded durable inbox work. Repeated wakes coalesce. A wake calls the provider only when it can promote eligible input. -Post-crash activity recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model durable activity identity, provider-dispatch ambiguity, required continuation, queue-opener reservation, retry policy, and visible recovery status together. +Post-crash continuation recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model provider-dispatch ambiguity, required continuation, queued-input promotion, retry policy, and visible recovery status together. It must not assume an enclosing durable execution identity that the Session model does not otherwise need. A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location. -Inbox promotion coalesces pending steers in durable admission order and opens one queued activity at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. +Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 893b1dc2dd3..b774c51c6bc 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -26,14 +26,14 @@ through legacy `SessionPrompt.loop(...)`: tool results, and assistant output - a scoped `ToolRegistry` advertises definitions and the first permission-checked `read` built-in -- local continuation reloads projected history and stops after 25 provider turns within one local drain activity +- local continuation reloads projected history, and promoting new user input resets the selected agent's configured provider-turn allowance - concurrent resumes for one Session join one process-local run while different Sessions remain concurrent Prompt admission now uses a durable `session_input` inbox rather than immediate -transcript projection. `steer` inputs coalesce into the active activity at the -next safe provider-turn boundary. `queue` inputs form a FIFO of future activities -that open one at a time. +transcript projection. `steer` inputs promote at the next safe provider-turn +boundary while the current drain requires continuation. `queue` inputs remain in +a FIFO until the Session would otherwise become idle and then promote one at a time. Next reviewed slices: @@ -53,16 +53,16 @@ Next reviewed slices: - add durable/clustered interruption, retries, and stale-owner fencing only as their slices become concrete -### Deferred durable activity recovery +### Deferred durable continuation recovery Do not infer that ambiguous provider work is safe to retry from an advisory wake. The first inbox-driven runner intentionally omits outer provider-attempt markers until they have a concrete consumer and a complete recovery policy. -Design post-crash activity recovery as one explicit slice. It should model: +Design post-crash continuation recovery as one explicit slice. It should model: -- durable activity identity and settlement -- queue-opener reservation and steer assignment +- promoted input and projected-history state +- queued-input promotion and steering assignment - provider-attempt preparation versus provider-dispatch ambiguity - required post-tool continuation across process loss - explicit `retry` and `abandon` decisions for unknown outcomes @@ -70,6 +70,9 @@ Design post-crash activity recovery as one explicit slice. It should model: - retry budget, backoff, visible recovery status, startup discovery, and future clustered ownership fencing +Do not introduce an enclosing durable execution identity solely to group these +facts; a process-local Session drain has no durable transcript boundary. + ## Plugin API design - James? We need to figure out how we want server plugins to work and what hooks are useful. From 909a1a6d788bd516c22cfe8eb6c1a10e7dcc92a1 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 22 Jun 2026 19:06:57 -0400 Subject: [PATCH 093/112] feat(plugin): add namespaced hook API (#33416) --- packages/core/src/agent.ts | 2 +- packages/core/src/aisdk.ts | 95 ++- packages/core/src/catalog.ts | 2 +- packages/core/src/command.ts | 2 +- packages/core/src/config/plugin/agent.ts | 2 +- packages/core/src/config/plugin/command.ts | 2 +- packages/core/src/config/plugin/external.ts | 99 +++ packages/core/src/config/plugin/provider.ts | 2 +- packages/core/src/config/plugin/reference.ts | 10 +- packages/core/src/config/plugin/skill.ts | 12 +- packages/core/src/integration.ts | 2 +- packages/core/src/plugin.ts | 255 +++---- packages/core/src/plugin/agent.ts | 6 +- packages/core/src/plugin/boot.ts | 120 ++-- packages/core/src/plugin/command.ts | 8 +- packages/core/src/plugin/host.ts | 148 +--- packages/core/src/plugin/internal.ts | 43 ++ packages/core/src/plugin/models-dev.ts | 8 +- packages/core/src/plugin/promise.ts | 86 +++ packages/core/src/plugin/provider.ts | 4 +- packages/core/src/plugin/provider/alibaba.ts | 5 +- .../src/plugin/provider/amazon-bedrock.ts | 8 +- .../core/src/plugin/provider/anthropic.ts | 5 +- packages/core/src/plugin/provider/azure.ts | 11 +- packages/core/src/plugin/provider/cerebras.ts | 5 +- .../plugin/provider/cloudflare-ai-gateway.ts | 5 +- .../plugin/provider/cloudflare-workers-ai.ts | 8 +- packages/core/src/plugin/provider/cohere.ts | 5 +- .../core/src/plugin/provider/deepinfra.ts | 5 +- packages/core/src/plugin/provider/dynamic.ts | 9 +- packages/core/src/plugin/provider/gateway.ts | 5 +- .../src/plugin/provider/github-copilot.ts | 8 +- packages/core/src/plugin/provider/gitlab.ts | 8 +- .../core/src/plugin/provider/google-vertex.ts | 14 +- packages/core/src/plugin/provider/google.ts | 5 +- packages/core/src/plugin/provider/groq.ts | 5 +- packages/core/src/plugin/provider/kilo.ts | 2 +- .../core/src/plugin/provider/llmgateway.ts | 6 +- packages/core/src/plugin/provider/mistral.ts | 5 +- packages/core/src/plugin/provider/nvidia.ts | 2 +- .../src/plugin/provider/openai-compatible.ts | 5 +- packages/core/src/plugin/provider/openai.ts | 8 +- packages/core/src/plugin/provider/opencode.ts | 6 +- .../core/src/plugin/provider/openrouter.ts | 5 +- .../core/src/plugin/provider/perplexity.ts | 5 +- .../core/src/plugin/provider/sap-ai-core.ts | 12 +- .../src/plugin/provider/snowflake-cortex.ts | 5 +- .../core/src/plugin/provider/togetherai.ts | 5 +- packages/core/src/plugin/provider/venice.ts | 5 +- packages/core/src/plugin/provider/vercel.ts | 5 +- packages/core/src/plugin/provider/xai.ts | 8 +- packages/core/src/plugin/provider/zenmux.ts | 2 +- packages/core/src/plugin/skill.ts | 2 +- packages/core/src/project/copy.ts | 3 - packages/core/src/reference.ts | 2 +- packages/core/src/reference/guidance.ts | 3 - packages/core/src/session/runner/model.ts | 3 - packages/core/src/skill.ts | 2 +- packages/core/src/skill/guidance.ts | 3 - packages/core/src/state.ts | 30 +- packages/core/src/tool/skill.ts | 3 - packages/core/test/agent.test.ts | 8 +- packages/core/test/catalog.test.ts | 2 +- packages/core/test/config/command.test.ts | 2 +- .../fixtures/plugin/directory-plugin.ts | 13 + packages/core/test/config/plugin.test.ts | 248 +++++++ packages/core/test/config/provider.test.ts | 7 +- packages/core/test/config/skill.test.ts | 11 +- packages/core/test/location-layer.test.ts | 47 +- packages/core/test/plugin.test.ts | 141 +--- packages/core/test/plugin/command.test.ts | 8 +- packages/core/test/plugin/fixture.ts | 15 +- .../plugin/fixtures/config-effect-plugin.ts | 15 + .../plugin/fixtures/config-promise-plugin.ts | 13 + .../test/plugin/fixtures/invalid-plugin.ts | 1 + packages/core/test/plugin/host.ts | 136 +--- packages/core/test/plugin/models-dev.test.ts | 17 +- packages/core/test/plugin/promise.test.ts | 67 ++ .../core/test/plugin/provider-alibaba.test.ts | 72 +- .../plugin/provider-amazon-bedrock.test.ts | 550 +++++++-------- .../test/plugin/provider-anthropic.test.ts | 48 +- .../provider-azure-cognitive-services.test.ts | 135 ++-- .../core/test/plugin/provider-azure.test.ts | 198 +++--- .../test/plugin/provider-cerebras.test.ts | 111 ++- .../provider-cloudflare-ai-gateway.test.ts | 285 ++++---- .../provider-cloudflare-workers-ai.test.ts | 180 +++-- .../core/test/plugin/provider-cohere.test.ts | 89 ++- .../test/plugin/provider-deepinfra.test.ts | 148 ++-- .../core/test/plugin/provider-dynamic.test.ts | 100 ++- .../core/test/plugin/provider-gateway.test.ts | 97 ++- .../plugin/provider-github-copilot.test.ts | 272 ++++---- .../core/test/plugin/provider-gitlab.test.ts | 256 ++++--- .../provider-google-vertex-anthropic.test.ts | 188 +++--- .../plugin/provider-google-vertex.test.ts | 116 ++-- .../core/test/plugin/provider-google.test.ts | 83 +-- .../core/test/plugin/provider-groq.test.ts | 119 ++-- .../core/test/plugin/provider-kilo.test.ts | 4 +- .../test/plugin/provider-llmgateway.test.ts | 5 +- .../core/test/plugin/provider-mistral.test.ts | 115 ++-- .../core/test/plugin/provider-nvidia.test.ts | 4 +- .../plugin/provider-openai-compatible.test.ts | 123 ++-- .../core/test/plugin/provider-openai.test.ts | 93 ++- .../test/plugin/provider-opencode.test.ts | 5 +- .../test/plugin/provider-openrouter.test.ts | 47 +- .../test/plugin/provider-perplexity.test.ts | 111 ++- .../test/plugin/provider-sap-ai-core.test.ts | 81 +-- .../plugin/provider-snowflake-cortex.test.ts | 144 ++-- .../test/plugin/provider-togetherai.test.ts | 124 ++-- .../core/test/plugin/provider-venice.test.ts | 110 ++- .../core/test/plugin/provider-vercel.test.ts | 27 +- .../core/test/plugin/provider-xai.test.ts | 110 ++- .../core/test/plugin/provider-zenmux.test.ts | 4 +- packages/core/test/plugin/skill.test.ts | 2 +- packages/core/test/reference-guidance.test.ts | 4 - packages/core/test/skill/guidance.test.ts | 19 +- packages/core/test/state.test.ts | 4 +- packages/core/test/tool-skill.test.ts | 18 +- packages/opencode/src/agent/agent.ts | 2 - packages/opencode/src/cli/cmd/debug/v2.ts | 2 - packages/opencode/src/session/system.ts | 2 - packages/plugin/package.json | 3 +- packages/plugin/src/v2/effect/README.md | 630 +++--------------- packages/plugin/src/v2/effect/agent.ts | 11 +- packages/plugin/src/v2/effect/aisdk.ts | 17 +- packages/plugin/src/v2/effect/catalog.ts | 20 +- packages/plugin/src/v2/effect/command.ts | 10 +- packages/plugin/src/v2/effect/context.ts | 22 + packages/plugin/src/v2/effect/host.ts | 27 - packages/plugin/src/v2/effect/index.ts | 18 +- packages/plugin/src/v2/effect/integration.ts | 10 +- packages/plugin/src/v2/effect/plugin.ts | 25 +- packages/plugin/src/v2/effect/reference.ts | 11 +- packages/plugin/src/v2/effect/registration.ts | 13 +- packages/plugin/src/v2/effect/skill.ts | 10 +- packages/plugin/src/v2/options.ts | 1 + packages/plugin/src/v2/promise/README.md | 103 +++ packages/plugin/src/v2/promise/agent.ts | 8 + packages/plugin/src/v2/promise/aisdk.ts | 18 + packages/plugin/src/v2/promise/catalog.ts | 8 + packages/plugin/src/v2/promise/command.ts | 8 + packages/plugin/src/v2/promise/context.ts | 22 + packages/plugin/src/v2/promise/index.ts | 17 + packages/plugin/src/v2/promise/integration.ts | 8 + packages/plugin/src/v2/promise/plugin.ts | 18 + packages/plugin/src/v2/promise/reference.ts | 8 + .../plugin/src/v2/promise/registration.ts | 11 + packages/plugin/src/v2/promise/skill.ts | 8 + packages/server/src/handlers/agent.ts | 2 - packages/server/src/handlers/model.ts | 9 - packages/server/src/handlers/provider.ts | 12 +- 150 files changed, 3286 insertions(+), 3916 deletions(-) create mode 100644 packages/core/src/config/plugin/external.ts create mode 100644 packages/core/src/plugin/internal.ts create mode 100644 packages/core/src/plugin/promise.ts create mode 100644 packages/core/test/config/fixtures/plugin/directory-plugin.ts create mode 100644 packages/core/test/config/plugin.test.ts create mode 100644 packages/core/test/plugin/fixtures/config-effect-plugin.ts create mode 100644 packages/core/test/plugin/fixtures/config-promise-plugin.ts create mode 100644 packages/core/test/plugin/fixtures/invalid-plugin.ts create mode 100644 packages/core/test/plugin/promise.test.ts create mode 100644 packages/plugin/src/v2/effect/context.ts delete mode 100644 packages/plugin/src/v2/effect/host.ts create mode 100644 packages/plugin/src/v2/options.ts create mode 100644 packages/plugin/src/v2/promise/README.md create mode 100644 packages/plugin/src/v2/promise/agent.ts create mode 100644 packages/plugin/src/v2/promise/aisdk.ts create mode 100644 packages/plugin/src/v2/promise/catalog.ts create mode 100644 packages/plugin/src/v2/promise/command.ts create mode 100644 packages/plugin/src/v2/promise/context.ts create mode 100644 packages/plugin/src/v2/promise/index.ts create mode 100644 packages/plugin/src/v2/promise/integration.ts create mode 100644 packages/plugin/src/v2/promise/plugin.ts create mode 100644 packages/plugin/src/v2/promise/reference.ts create mode 100644 packages/plugin/src/v2/promise/registration.ts create mode 100644 packages/plugin/src/v2/promise/skill.ts diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 18e9e59c0ff..f27b5c3ef8b 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -108,7 +108,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, get: Effect.fn("AgentV2.get")(function* (id) { return state.get().agents.get(id) }), diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 769941fd276..9ea79394f34 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -1,14 +1,27 @@ export * as AISDK from "./aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Cause, Context, Effect, Layer, Schema } from "effect" +import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" import { ModelV2 } from "./model" -import { EventV2 } from "./event" -import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" +import { State } from "./state" type SDK = any +export interface SDKEvent { + readonly model: ModelV2.Info + readonly package: string + readonly options: Record + sdk?: SDK +} + +export interface LanguageEvent { + readonly model: ModelV2.Info + readonly sdk: SDK + readonly options: Record + language?: LanguageModelV3 +} + function wrapSSE(res: Response, ms: number, ctl: AbortController) { if (typeof ms !== "number" || ms <= 0) return res if (!res.body) return res @@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) { } export interface Interface { + readonly hook: { + readonly sdk: ( + callback: (event: SDKEvent) => Effect.Effect | void, + ) => Effect.Effect + readonly language: ( + callback: (event: LanguageEvent) => Effect.Effect | void, + ) => Effect.Effect + } + readonly runSDK: (event: SDKEvent) => Effect.Effect + readonly runLanguage: (event: LanguageEvent) => Effect.Effect readonly language: (model: ModelV2.Info) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/AISDK") {} -export const layer = Layer.effect( +export const locationLayer = Layer.effect( Service, Effect.gen(function* () { - const plugin = yield* PluginV2.Service + let sdkHooks: ((event: SDKEvent) => Effect.Effect | void)[] = [] + let languageHooks: ((event: LanguageEvent) => Effect.Effect | void)[] = [] const languages = new Map() const sdks = new Map() - return Service.of({ + const register = ( + hooks: () => ((event: Event) => Effect.Effect | void)[], + update: (hooks: ((event: Event) => Effect.Effect | void)[]) => void, + ) => + Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect | void) { + const scope = yield* Scope.Scope + let active = true + update([...hooks(), callback]) + const dispose = Effect.sync(() => { + if (!active) return + active = false + update(hooks().filter((item) => item !== callback)) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }) + + const run = Effect.fnUntraced(function* ( + hooks: readonly ((event: Event) => Effect.Effect | void)[], + event: Event, + ) { + for (const hook of hooks) { + const result = hook(event) + if (Effect.isEffect(result)) yield* result + } + return event + }) + + const service = Service.of({ + hook: { + sdk: register( + () => sdkHooks, + (next) => (sdkHooks = next), + ), + language: register( + () => languageHooks, + (next) => (languageHooks = next), + ), + }, + runSDK: (event) => run(sdkHooks, event), + runLanguage: (event) => run(languageHooks, event), language: Effect.fn("AISDK.language")(function* (model) { const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}` const existing = languages.get(key) @@ -148,26 +212,14 @@ export const layer = Layer.effect( }) const sdk = sdks.get(sdkKey) ?? - (yield* plugin - .trigger("aisdk.sdk", { model, package: model.api.package, options }, {}) - .pipe(initError(model.providerID))).sdk + (yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk if (!sdk) return yield* new InitError({ providerID: model.providerID, cause: new Error("No AISDK provider plugin returned an SDK"), }) sdks.set(sdkKey, sdk) - const result = yield* plugin - .trigger( - "aisdk.language", - { - model, - sdk, - options, - }, - {}, - ) - .pipe(initError(model.providerID)) + const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID)) const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe( initError(model.providerID), ) @@ -175,7 +227,8 @@ export const layer = Layer.effect( return language }), }) + return service }), ) -export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))) +export const defaultLayer = locationLayer diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index ed982cb6d7f..ade2d646075 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -170,7 +170,7 @@ export const layer = Layer.effect( }) const result: Interface = { transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 622702e9946..947ad311e79 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -52,7 +52,7 @@ export const layer = Layer.effect( }) return Service.of({ - rebuild: state.rebuild, + reload: state.reload, transform: state.transform, get: Effect.fn("CommandV2.get")(function* (name) { return state.get().commands.get(name) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index ffc268a0e24..48efe758047 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,6 +1,6 @@ export * as ConfigAgentPlugin from "./agent" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import path from "path" import { Effect, Option, Schema } from "effect" import { AgentV2 } from "../../agent" diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index a88c60559e9..f9b31f8e45a 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,6 +1,6 @@ export * as ConfigCommandPlugin from "./command" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import path from "path" import { Effect, Option, Schema } from "effect" import { CommandV2 } from "../../command" diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts new file mode 100644 index 00000000000..d81d9f7c871 --- /dev/null +++ b/packages/core/src/config/plugin/external.ts @@ -0,0 +1,99 @@ +export * as ConfigExternalPlugin from "./external" + +import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise" +import { Effect, Schema } from "effect" +import path from "path" +import { fileURLToPath, pathToFileURL } from "url" +import { Config } from "../../config" +import { FSUtil } from "../../fs-util" +import { Location } from "../../location" +import { Npm } from "../../npm" +import { define } from "../../plugin/internal" +import { PluginPromise } from "../../plugin/promise" + +const PluginModule = Schema.Struct({ + default: Schema.Union([ + Schema.Struct({ + id: Schema.String, + effect: Schema.declare( + (input): input is EffectPlugin["effect"] => typeof input === "function", + ), + }), + Schema.Struct({ + id: Schema.String, + setup: Schema.declare( + (input): input is PromisePlugin["setup"] => typeof input === "function", + ), + }), + ]), +}) + +export const Plugin = define({ + id: "config-plugin", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const loaded: EffectPlugin[] = [] + + yield* ctx.plugin.transform((plugins) => { + for (const plugin of loaded) plugins.add(plugin) + }) + + yield* Effect.gen(function* () { + const configured: { package: string; options?: Record }[] = [] + + for (const entry of yield* config.entries()) { + if (entry.type === "document") { + const directory = entry.path ? path.dirname(entry.path) : location.directory + for (const item of entry.info.plugins ?? []) { + const ref = typeof item === "string" ? { package: item } : item + const packageName = (() => { + if (ref.package.startsWith("file://")) return fileURLToPath(ref.package) + if (ref.package.startsWith("./") || ref.package.startsWith("../")) { + return path.resolve(directory, ref.package) + } + return ref.package + })() + configured.push({ package: packageName, options: ref.options }) + } + } + + if (entry.type === "directory") { + const files = yield* fs + .glob("{plugin,plugins}/*.{ts,js}", { + cwd: entry.path, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .pipe(Effect.orElseSucceed(() => [])) + files.sort() + for (const file of files) configured.push({ package: file }) + } + } + + for (const ref of configured) { + yield* Effect.gen(function* () { + const entrypoint = path.isAbsolute(ref.package) + ? pathToFileURL(ref.package).href + : (yield* npm.add(ref.package)).entrypoint + if (!entrypoint) return + + const mod = yield* Effect.promise(() => import(entrypoint)) + const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default + const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) + loaded.push({ + id: plugin.id, + effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), + }) + }).pipe(Effect.ignoreCause) + } + + yield* ctx.plugin.reload() + }).pipe(Effect.forkScoped({ startImmediately: true })) + }), +}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 0171fee37bb..6fb13f1c63c 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,6 +1,6 @@ export * as ConfigProviderPlugin from "./provider" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import { Effect } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index f511736e11f..82487e4a898 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -1,24 +1,28 @@ export * as ConfigReferencePlugin from "./reference" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import path from "path" import { Effect } from "effect" import { Config } from "../../config" import { ConfigReference } from "../reference" import { Reference } from "../../reference" import { AbsolutePath } from "../../schema" +import { Global } from "../../global" +import { Location } from "../../location" export const Plugin = define({ id: "core/config-reference", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service + const location = yield* Location.Service + const global = yield* Global.Service yield* ctx.reference.transform( Effect.fn(function* (draft) { const entries = new Map() for (const doc of (yield* config.entries()).filter( (entry): entry is Config.Document => entry.type === "document", )) { - const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory + const directory = doc.path ? path.dirname(doc.path) : location.directory for (const [name, entry] of Object.entries(doc.info.references ?? {})) { if (!validAlias(name)) continue entries.set( @@ -27,7 +31,7 @@ export const Plugin = define({ ? new Reference.LocalSource({ type: "local", path: AbsolutePath.make( - localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path), + localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), ), description: typeof entry === "string" ? undefined : entry.description, hidden: typeof entry === "string" ? undefined : entry.hidden, diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 9f6a99d8b1a..eca8b5ccae5 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -1,16 +1,20 @@ export * as ConfigSkillPlugin from "./skill" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import path from "path" import { Effect } from "effect" import { Config } from "../../config" import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" +import { Global } from "../../global" +import { Location } from "../../location" export const Plugin = define({ id: "config-skill", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service + const global = yield* Global.Service + const location = yield* Location.Service yield* ctx.skill.transform( Effect.fn(function* (draft) { const entries = yield* config.entries() @@ -29,13 +33,11 @@ export const Plugin = define({ draft.source(new SkillV2.UrlSource({ type: "url", url: item })) continue } - const expanded = item.startsWith("~/") ? path.join(ctx.path.home, item.slice(2)) : item + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item draft.source( new SkillV2.DirectorySource({ type: "directory", - path: AbsolutePath.make( - path.isAbsolute(expanded) ? expanded : path.join(ctx.location.directory, expanded), - ), + path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), }), ) } diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 03192921b93..1e1e613e3fa 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -432,7 +432,7 @@ export const locationLayer = Layer.effect( return Service.of({ transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 0a02ddfa7f5..85c51ea7dba 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,12 +1,17 @@ export * as PluginV2 from "./plugin" -import { createDraft, finishDraft, type Draft } from "immer" -import type { LanguageModelV3 } from "@ai-sdk/provider" import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" -import type { ModelV2 } from "./model" -import type { Catalog } from "./catalog" +import type { Plugin, PluginDraft } from "@opencode-ai/plugin/v2/effect" +import { AgentV2 } from "./agent" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" import { EventV2 } from "./event" +import { Integration } from "./integration" import { KeyedMutex } from "./effect/keyed-mutex" +import { PluginHost } from "./plugin/host" +import { Reference } from "./reference" +import { SkillV2 } from "./skill" import { State } from "./state" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) @@ -21,69 +26,9 @@ export const Event = { }), } -type HookSpec = { - "catalog.transform": { - input: Catalog.Draft - output: {} - } - "aisdk.language": { - input: { - model: ModelV2.Info - sdk: any - options: Record - } - output: { - language?: LanguageModelV3 - } - } - "aisdk.sdk": { - input: { - model: ModelV2.Info - package: string - options: Record - } - output: { - sdk?: any - } - } -} - -export type Hooks = { - [Name in keyof HookSpec]: Readonly & { - -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object - ? Draft - : HookSpec[Name]["output"][Field] - } -} - -export type HookFunctions = { - [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect -} - -export type HookInput = HookSpec[Name]["input"] -export type HookOutput = HookSpec[Name]["output"] - export interface Interface { - readonly add: (input: { - id: string - effect: Effect.Effect - }) => Effect.Effect - readonly remove: (id: ID) => Effect.Effect - readonly hook: ( - name: Name, - callback: (input: Hooks[Name]) => Effect.Effect | void, - ) => Effect.Effect - readonly triggerFor: ( - id: ID, - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> - readonly trigger: ( - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> + readonly transform: State.Transform + readonly reload: State.Reload } export class Service extends Context.Service()("@opencode/v2/Plugin") {} @@ -91,127 +36,85 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - let hooks: { - id: ID - hooks: HookFunctions - scope: Scope.Closeable - }[] = [] - let registrations: { - [Name in keyof Hooks]: { - name: Name - callback: (input: Hooks[Name]) => Effect.Effect | void - } - }[keyof Hooks][] = [] const events = yield* EventV2.Service const locks = KeyedMutex.makeUnsafe() const scope = yield* Scope.make() + const active = new Map() + let host: Parameters[0] + + const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters[0]) { + const id = ID.make(plugin.id) + yield* locks.withLock(id)( + Effect.gen(function* () { + const existing = active.get(id) + if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + + const child = yield* Scope.fork(scope) + yield* plugin.effect(host).pipe( + Scope.provide(child), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + ) + active.set(id, child) + yield* events.publish(Event.Added, { id }) + }), + ) + }) + + const detach = Effect.fn("Plugin.detach")(function* (id: ID) { + yield* locks.withLock(id)( + Effect.gen(function* () { + const current = active.get(id) + active.delete(id) + if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) + }), + ) + }) + + const state = State.create, PluginDraft>({ + initial: () => new Map(), + draft: (draft) => ({ + list: () => Array.from(draft.values()), + add: (plugin) => draft.set(ID.make(plugin.id), plugin), + remove: (id) => draft.delete(ID.make(id)), + }), + finalize: (draft) => + State.batch( + Effect.gen(function* () { + const desired = new Set() + for (const plugin of draft.list()) desired.add(ID.make(plugin.id)) + + for (const id of active.keys()) { + if (!desired.has(id)) yield* detach(id) + } + + for (const plugin of draft.list()) yield* attach(plugin, host) + }).pipe(Effect.withSpan("Plugin.reconcile")), + ), + }) - // One registry-owned scope lets shutdown remove every plugin transform in one batch. yield* Effect.addFinalizer((exit) => Effect.gen(function* () { - hooks = [] + active.clear() yield* State.batch(Scope.close(scope, exit)) }), ) - const svc = Service.of({ - add: Effect.fn("Plugin.add")(function* (input) { - const id = ID.make(input.id) - yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === id) - if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) - const childScope = yield* Scope.fork(scope) - const result = yield* input.effect.pipe( - Scope.provide(childScope), - Effect.withSpan("Plugin.load", { - attributes: { - "plugin.id": id, - }, - }), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)), - ) - const next = { - id, - hooks: result ?? {}, - scope: childScope, - } - hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next] - yield* events.publish(Event.Added, { id }) - }), - ) - }), - trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { - return yield* svc.triggerFor(ID.make("*"), name, input, output) - }), - triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) { - const draftEntries = new Map>() - const event = { - ...input, - ...output, - } as Record - - for (const [field, value] of Object.entries(output)) { - if (value && typeof value === "object") { - draftEntries.set(field, createDraft(value)) - event[field] = draftEntries.get(field) - } - } - - for (const item of hooks) { - if (id !== ID.make("*") && item.id !== id) continue - const match = item.hooks[name] - if (!match) continue - yield* match(event as any).pipe( - Effect.withSpan(`Plugin.hook.${name}`, { - attributes: { - plugin: item.id, - hook: name, - }, - }), - ) - } - - for (const item of registrations) { - if (item.name !== name) continue - const result = item.callback(event as never) - if (Effect.isEffect(result)) yield* result - } - - for (const [field, draft] of draftEntries) { - event[field] = finishDraft(draft) - } - - return event as any - }), - remove: Effect.fn("Plugin.remove")(function* (id) { - yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === id) - hooks = hooks.filter((item) => item.id !== id) - if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) - }), - ) - }), - hook: Effect.fn("Plugin.hook")(function* (name, callback) { - const scope = yield* Scope.Scope - const registration = { name, callback } as (typeof registrations)[number] - let active = true - registrations = [...registrations, registration] - const dispose = Effect.sync(() => { - if (!active) return - active = false - registrations = registrations.filter((item) => item !== registration) - }) - yield* Scope.addFinalizer(scope, dispose) - return { dispose } - }), + const service = Service.of({ + transform: state.transform, + reload: state.reload, }) - return svc + host = yield* PluginHost.make(service) + return service }), ) -export const locationLayer = layer - -// opencode -// sdcok +export const locationLayer = layer.pipe( + Layer.provideMerge(AgentV2.locationLayer), + Layer.provideMerge(AISDK.locationLayer), + Layer.provideMerge(Catalog.locationLayer), + Layer.provideMerge(CommandV2.locationLayer), + Layer.provideMerge(Integration.locationLayer), + Layer.provideMerge(Reference.locationLayer), + Layer.provideMerge(SkillV2.locationLayer), +) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 735ddd31072..9a763c7ea9b 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,10 +1,11 @@ export * as AgentPlugin from "./agent" import path from "path" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "./internal" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" +import { Location } from "../location" import { PermissionV2 } from "../permission" const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") @@ -99,7 +100,8 @@ Rules: export const Plugin = define({ id: "agent", effect: Effect.fn(function* (ctx) { - const worktree = ctx.location.directory + const location = yield* Location.Service + const worktree = location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const readonlyExternalDirectory: PermissionV2.Ruleset = [ { action: "external_directory", resource: "*", effect: "ask" }, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 3acd94a218f..34b52417d8c 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,9 +1,9 @@ export * as PluginBoot from "./boot" -import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect" -import { Context, Deferred, Effect, Layer } from "effect" +import { Effect, Layer } from "effect" import { Integration } from "../integration" import { AgentV2 } from "../agent" +import { AISDK } from "../aisdk" import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Config } from "../config" @@ -11,6 +11,7 @@ import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigReferencePlugin } from "../config/plugin/reference" +import { ConfigExternalPlugin } from "../config/plugin/external" import { EventV2 } from "../event" import { FSUtil } from "../fs-util" import { FileSystem } from "../filesystem" @@ -29,18 +30,9 @@ import { SkillV2 } from "../skill" import { Reference } from "../reference" import { State } from "../state" import { PluginHost } from "./host" +import { PluginInternal } from "./internal" -type InternalPlugin = PublicPlugin - -export interface Interface { - readonly add: (plugin: PublicPlugin) => Effect.Effect - readonly wait: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/PluginBoot") {} - -export const layer = Layer.effect( - Service, +export const locationLayer = Layer.effectDiscard( Effect.gen(function* () { const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service @@ -57,75 +49,47 @@ export const layer = Layer.effect( const global = yield* Global.Service const skill = yield* SkillV2.Service const reference = yield* Reference.Service - const host = yield* PluginHost.make() - const done = yield* Deferred.make() + const host = yield* PluginHost.make(plugin) - const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) { - yield* plugin.add({ - id: input.id, - effect: input - .effect(host) - .pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Integration.Service, integration), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(FileSystem.Service, filesystem), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, reference), - ), - }) - }) + const add = (input: PluginInternal.Plugin) => + input + .effect({ ...host, options: {} }) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ) - const boot = Effect.gen(function* () { - yield* State.batch( - Effect.gen(function* () { - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - yield* add(ModelsDevPlugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) - for (const item of ProviderPlugins) { - yield* add(item) - } - }), - ) - }).pipe(Effect.withSpan("PluginBoot.boot")) - - yield* boot.pipe( - Effect.exit, - Effect.flatMap((exit) => Deferred.done(done, exit)), - Effect.forkScoped, - ) - - return Service.of({ - add: (input) => - Deferred.await(done).pipe( - Effect.andThen( - plugin.add({ - id: input.id, - effect: input.effect(host), - }), - ), - ), - wait: () => Deferred.await(done), - }) + yield* State.batch( + Effect.gen(function* () { + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigReferencePlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + }), + ).pipe(Effect.withSpan("PluginBoot.boot")) }), -) - -export const locationLayer = layer.pipe( +).pipe( Layer.provideMerge(PluginV2.locationLayer), + Layer.provideMerge(AISDK.locationLayer), Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Catalog.locationLayer), Layer.provideMerge(CommandV2.locationLayer), diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 121bc0e6ccb..cbafd68b502 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,20 +1,22 @@ export * as CommandPlugin from "./command" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "./internal" import { Effect } from "effect" +import { Location } from "../location" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" export const Plugin = define({ id: "command", effect: Effect.fn(function* (ctx) { + const location = yield* Location.Service yield* ctx.command.transform((draft) => { draft.update("init", (command) => { - command.template = PROMPT_INITIALIZE.replace("${path}", ctx.location.project.directory) + command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) command.description = "guided AGENTS.md setup" }) draft.update("review", (command) => { - command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory) + command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 833671c0a58..27afc14d2af 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,58 +1,31 @@ export * as PluginHost from "./host" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import type { PluginHost as Interface } from "@opencode-ai/plugin/v2/effect" -import type { Event as SDKEvent, ModelV2Info } from "@opencode-ai/sdk/v2/types" -import { Effect, Schema, Stream } from "effect" +import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema } from "effect" import { AgentV2 } from "../agent" +import { AISDK } from "../aisdk" import { Catalog } from "../catalog" import { CommandV2 } from "../command" -import { EventV2 } from "../event" -import { FileSystem } from "../filesystem" -import { Global } from "../global" import { Integration } from "../integration" -import { Location } from "../location" import { ModelV2 } from "../model" -import { Npm } from "../npm" -import { PluginV2 } from "../plugin" +import type { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" import { Reference } from "../reference" import { SkillV2 } from "../skill" -type EventMap = { [Item in SDKEvent as Item["type"]]: Item } -type SDKHook = (event: { - readonly model: ModelV2Info - readonly package: string - readonly options: Record - sdk?: any -}) => Effect.Effect | void -type LanguageHook = (event: { - readonly model: ModelV2Info - readonly sdk: any - readonly options: Record - language?: LanguageModelV3 -}) => Effect.Effect | void - -export const make = Effect.fn("PluginHost.make")(function* () { +export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { const agents = yield* AgentV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service - const events = yield* EventV2.Service - const filesystem = yield* FileSystem.Service - const global = yield* Global.Service const integration = yield* Integration.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const plugin = yield* PluginV2.Service const reference = yield* Reference.Service const skill = yield* SkillV2.Service return { + options: {}, agent: { - get: (id) => agents.get(AgentV2.ID.make(id)), - default: agents.default, - list: agents.all, - rebuild: agents.rebuild, + reload: agents.reload, transform: (callback) => agents.transform((draft) => callback({ @@ -65,51 +38,35 @@ export const make = Effect.fn("PluginHost.make")(function* () { ), }, aisdk: { - hook: (name, callback) => { - if (name === "sdk") { - const run = callback as SDKHook - return plugin.hook("aisdk.sdk", (event) => { - const output = { - model: event.model, - package: event.package, - options: event.options, - sdk: event.sdk, - } - const result = run(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), - ) - }) - } - const run = callback as LanguageHook - return plugin.hook("aisdk.language", (event) => { + sdk: (callback) => + aisdk.hook.sdk((event) => { + const output = { + model: event.model, + package: event.package, + options: event.options, + sdk: event.sdk, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }), + language: (callback) => + aisdk.hook.language((event) => { const output = { model: event.model, sdk: event.sdk, options: event.options, language: event.language, } - const result = run(output) + const result = callback(output) return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( Effect.tap(() => Effect.sync(() => (event.language = output.language))), ) - }) - }, + }), }, catalog: { - provider: { - get: (id) => catalog.provider.get(ProviderV2.ID.make(id)), - list: catalog.provider.all, - available: catalog.provider.available, - }, - model: { - get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), - list: catalog.model.all, - available: catalog.model.available, - default: catalog.model.default, - small: (providerID) => catalog.model.small(ProviderV2.ID.make(providerID)), - }, - rebuild: catalog.rebuild, + reload: catalog.reload, transform: (callback) => catalog.transform((draft) => callback({ @@ -135,41 +92,11 @@ export const make = Effect.fn("PluginHost.make")(function* () { ), }, command: { - get: commands.get, - list: commands.list, - rebuild: commands.rebuild, + reload: commands.reload, transform: commands.transform, }, - event: { - subscribe: (type: Type): Stream.Stream => - Stream.unwrap( - Effect.sync(() => { - const definition = EventV2.registry.get(type) - if (!definition) throw new Error(`Unknown event type: ${type}`) - const encode = Schema.encodeUnknownSync(definition.data as Schema.Codec) - return events.subscribe(definition).pipe( - Stream.map( - (event) => - ({ - id: event.id, - type: event.type, - properties: encode(event.data), - }) as unknown as EventMap[Type], - ), - ) - }), - ), - }, - filesystem: { - read: (input) => filesystem.read(Schema.decodeUnknownSync(FileSystem.ReadInput)(input)), - list: (input) => filesystem.list(Schema.decodeUnknownSync(FileSystem.ListInput)(input ?? {})), - find: (input) => filesystem.find(Schema.decodeUnknownSync(FileSystem.FindInput)(input)), - glob: (input) => filesystem.glob(Schema.decodeUnknownSync(FileSystem.GlobInput)(input)), - }, integration: { - get: (id) => integration.get(Integration.ID.make(id)), - list: integration.list, - rebuild: integration.rebuild, + reload: integration.reload, transform: (callback) => integration.transform((draft) => callback({ @@ -198,19 +125,12 @@ export const make = Effect.fn("PluginHost.make")(function* () { }), ), }, - location, - npm, - path: { - home: global.home, - data: global.data, - cache: global.cache, - config: global.config, - state: global.state, - temp: global.tmp, + plugin: { + reload: plugin.reload, + transform: plugin.transform, }, reference: { - list: reference.list, - rebuild: reference.rebuild, + reload: reference.reload, transform: (callback) => reference.transform((draft) => callback({ @@ -221,9 +141,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { ), }, skill: { - sources: skill.sources, - list: skill.list, - rebuild: skill.rebuild, + reload: skill.reload, transform: (callback) => skill.transform((draft) => callback({ diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts new file mode 100644 index 00000000000..ba7e248f684 --- /dev/null +++ b/packages/core/src/plugin/internal.ts @@ -0,0 +1,43 @@ +export * as PluginInternal from "./internal" + +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Effect, Scope } from "effect" +import type { AgentV2 } from "../agent" +import type { Catalog } from "../catalog" +import type { CommandV2 } from "../command" +import type { Config } from "../config" +import type { EventV2 } from "../event" +import type { FileSystem } from "../filesystem" +import type { FSUtil } from "../fs-util" +import type { Global } from "../global" +import type { Integration } from "../integration" +import type { Location } from "../location" +import type { ModelsDev } from "../models-dev" +import type { Npm } from "../npm" +import type { Reference } from "../reference" +import type { SkillV2 } from "../skill" + +export type Requirements = + | AgentV2.Service + | Catalog.Service + | CommandV2.Service + | Config.Service + | EventV2.Service + | FileSystem.Service + | FSUtil.Service + | Global.Service + | Integration.Service + | Location.Service + | ModelsDev.Service + | Npm.Service + | Reference.Service + | SkillV2.Service + +export interface Plugin { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 34f46685007..04f1f092a09 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,5 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "./internal" import { Effect, Stream } from "effect" +import { EventV2 } from "../event" import { ModelV2 } from "../model" import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" @@ -52,6 +53,7 @@ export const ModelsDevPlugin = define({ id: "models-dev", effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service + const events = yield* EventV2.Service yield* ctx.integration.transform( Effect.fn(function* (integrations) { const data = yield* modelsDev.get() @@ -128,8 +130,8 @@ export const ModelsDevPlugin = define({ } }), ) - yield* ctx.event.subscribe("models-dev.refreshed").pipe( - Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))), + yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts new file mode 100644 index 00000000000..58fb4ba0dcb --- /dev/null +++ b/packages/core/src/plugin/promise.ts @@ -0,0 +1,86 @@ +export * as PluginPromise from "./promise" + +import { define } from "@opencode-ai/plugin/v2/effect" +import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise" +import { Effect, Scope } from "effect" + +// The Effect host hands back this registration shape; mirror it structurally so +// we do not have to alias the Effect package's `Registration` against the Promise one. +type HostRegistration = { readonly dispose: Effect.Effect } + +/** + * Adapts a Promise plugin into an Effect plugin so the existing Effect-only + * loader (`PluginV2` / `PluginBoot`) can run it unchanged. + * + * Hook registrations created during the async `setup` attach to the plugin's + * scope, so unloading the plugin disposes them. The captured fiber context + * preserves boot-time batching, so Promise-plugin transforms still coalesce + * into one reload per domain. + */ +export function fromPromise(plugin: Plugin) { + return define({ + id: plugin.id, + effect: (host) => + Effect.gen(function* () { + const scope = yield* Scope.Scope + const context = yield* Effect.context() + + // Run a hook registration on the plugin scope and resolve once it is registered. + const register = (effect: Effect.Effect): Promise => + Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({ + dispose: () => Effect.runPromiseWith(context)(registration.dispose), + })) + + const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) + + const transform = + (domain: { + transform: ( + callback: (draft: Draft) => Effect.Effect | void, + ) => Effect.Effect + }) => + (callback: (draft: Draft) => Promise | void) => + register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft))))) + + const context2: PluginContext = { + options: host.options, + agent: { + transform: transform(host.agent), + reload: () => run(host.agent.reload()), + }, + aisdk: { + sdk: (callback) => + register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), + language: (callback) => + register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, + catalog: { + transform: transform(host.catalog), + reload: () => run(host.catalog.reload()), + }, + command: { + transform: transform(host.command), + reload: () => run(host.command.reload()), + }, + integration: { + transform: transform(host.integration), + reload: () => run(host.integration.reload()), + }, + plugin: { + transform: transform(host.plugin), + reload: () => run(host.plugin.reload()), + }, + reference: { + transform: transform(host.reference), + reload: () => run(host.reference.reload()), + }, + skill: { + transform: transform(host.skill), + reload: () => run(host.skill.reload()), + }, + } + + yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) + }), + }) +} diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index ea3939b750d..1749b474ed3 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel" import { VenicePlugin } from "./provider/venice" import { XAIPlugin } from "./provider/xai" import { ZenmuxPlugin } from "./provider/zenmux" +import type { PluginInternal } from "./internal" +import type { Scope } from "effect" -export const ProviderPlugins = [ +export const ProviderPlugins: PluginInternal.Plugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index a75d0c0d08b..c5c4be0d0be 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const AlibabaPlugin = define({ id: "alibaba", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index fe7bc10365b..0995cf1c172 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -78,8 +78,7 @@ export const AmazonBedrockPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } @@ -112,8 +111,7 @@ export const AmazonBedrockPlugin = define({ evt.sdk = mod.createAmazonBedrock(options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 7c36d6dd9be..cf883a0687f 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const AnthropicPlugin = define({ id: "anthropic", @@ -16,8 +16,7 @@ export const AnthropicPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 9115dcefe3c..2e1f9d9b48f 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -28,8 +28,7 @@ export const AzurePlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { @@ -47,8 +46,7 @@ export const AzurePlugin = define({ evt.sdk = mod.createAzure(evt.options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) @@ -74,8 +72,7 @@ export const AzureCognitiveServicesPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index f82f3eacc65..0fd651160fe 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const CerebrasPlugin = define({ id: "cerebras", @@ -15,8 +15,7 @@ export const CerebrasPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index d6ba76db60c..d416f6f19d3 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,13 +1,12 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const CloudflareAIGatewayPlugin = define({ id: "cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 3904ee5b833..1a1c533eb55 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,7 +1,7 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") @@ -21,8 +21,7 @@ export const CloudflareWorkersAIPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -38,8 +37,7 @@ export const CloudflareWorkersAIPlugin = define({ ) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.api.id) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index df9f64685d0..0ca0708577a 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const CoherePlugin = define({ id: "cohere", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index 2f62029a57b..1b23e08ba4a 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const DeepInfraPlugin = define({ id: "deepinfra", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index 4ab7c738da1..c84a6ed51f8 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,18 +1,19 @@ import { Effect } from "effect" import { pathToFileURL } from "url" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" +import { Npm } from "../../npm" export const DynamicProviderPlugin = define({ id: "dynamic-provider", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + const npm = yield* Npm.Service + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.sdk) return const installedPath = evt.package.startsWith("file://") ? evt.package - : (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 6e8f9186108..f097dcaca3f 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const GatewayPlugin = define({ id: "gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 6adc366c04f..682579d7a99 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function shouldUseResponses(modelID: string) { @@ -25,16 +25,14 @@ export const GithubCopilotPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 70af0716463..8723cdaac2e 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,14 +1,13 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" export const GitLabPlugin = define({ id: "gitlab", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) @@ -32,8 +31,7 @@ export const GitLabPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index e3d42950473..4e643c9f519 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -84,8 +84,7 @@ export const GoogleVertexPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) @@ -104,8 +103,7 @@ export const GoogleVertexPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) @@ -139,8 +137,7 @@ export const GoogleVertexAnthropicPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) @@ -166,8 +163,7 @@ export const GoogleVertexAnthropicPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 19b240b7016..476af5b9121 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const GooglePlugin = define({ id: "google", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index 6a6e14ae6d6..0bddb443095 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const GroqPlugin = define({ id: "groq", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index f57322a9030..6ee6670ee5d 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const KiloPlugin = define({ id: "kilo", diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 5c9802065bf..eafc5edd6c7 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,9 +1,11 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" +import { Integration } from "../../integration" export const LLMGatewayPlugin = define({ id: "llmgateway", effect: Effect.fn(function* (ctx) { + const integrations = yield* Integration.Service yield* ctx.catalog.transform( Effect.fn(function* (evt) { for (const item of evt.provider.list()) { @@ -11,7 +13,7 @@ export const LLMGatewayPlugin = define({ if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue - if (!(yield* ctx.integration.get(item.provider.id))) continue + if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["X-Title"] = "opencode" diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index a799c2b451a..a7319756598 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const MistralPlugin = define({ id: "mistral", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 25f695d9526..449599727c2 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const NvidiaPlugin = define({ id: "nvidia", diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index de2da085fe0..d602ed0ff95 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const OpenAICompatiblePlugin = define({ id: "openai-compatible", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 07fb2fec975..c1734d62a19 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" import { Integration } from "../../integration" import { browser, headless } from "./openai-auth" @@ -27,16 +27,14 @@ export const OpenAIPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) evt.sdk = mod.createOpenAI(evt.options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.openai) return evt.language = evt.sdk.responses(evt.model.api.id) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 1414d5a1ece..d50992b51df 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,16 +1,18 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" +import { Integration } from "../../integration" export const OpencodePlugin = define({ id: "opencode", effect: Effect.fn(function* (ctx) { + const integrations = yield* Integration.Service let hasKey = false yield* ctx.catalog.transform( Effect.fn(function* (evt) { const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return - const integration = yield* ctx.integration.get(item.provider.id) + const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) hasKey = Boolean( process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, ) diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 81c4911d969..0f295fb0950 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const OpenRouterPlugin = define({ id: "openrouter", @@ -25,8 +25,7 @@ export const OpenRouterPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index c9e1873deeb..44c1ef2fc0a 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const PerplexityPlugin = define({ id: "perplexity", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index b3675961bf2..8c668d8b414 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,13 +1,14 @@ import { Effect } from "effect" import { pathToFileURL } from "url" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" +import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" export const SapAICorePlugin = define({ id: "sap-ai-core", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + const npm = yield* Npm.Service + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = @@ -17,7 +18,7 @@ export const SapAICorePlugin = define({ const installedPath = evt.package.startsWith("file://") ? evt.package - : (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -35,8 +36,7 @@ export const SapAICorePlugin = define({ ) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.api.id) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 48e5e73aad9..788ac63eb03 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -67,8 +67,7 @@ export function cortexFetch(upstream: FetchLike = fetch) { export const SnowflakeCortexPlugin = define({ id: "snowflake-cortex", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index 10eb849bafc..8022e0de668 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const TogetherAIPlugin = define({ id: "togetherai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 2d2bc4dc91e..1a602ffd50b 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const VenicePlugin = define({ id: "venice", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 45f117158d2..00f5601430c 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const VercelPlugin = define({ id: "vercel", @@ -16,8 +16,7 @@ export const VercelPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 5fc10e8675b..8145a3480a0 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,20 +1,18 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" export const XAIPlugin = define({ id: "xai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.api.id) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 497561e00ed..29adebc0ee5 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const ZenmuxPlugin = define({ id: "zenmux", diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 1dec8ba3570..0e9c85abe9a 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,7 +2,7 @@ export * as SkillPlugin from "./skill" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "./internal" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 0e3246b3b2a..441c380d7c7 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -13,7 +13,6 @@ import { Slug } from "../util/slug" import { EventV2 } from "../event" import { Database } from "../database/database" import { Location } from "../location" -import { PluginBoot } from "../plugin/boot" export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) export type StrategyID = typeof StrategyID.Type @@ -125,10 +124,8 @@ export class Service extends Context.Service()("@opencode/Pr export const refreshAfterBoot = Effect.gen(function* () { const location = yield* Location.Service - const boot = yield* PluginBoot.Service const copies = yield* Service yield* Effect.gen(function* () { - yield* boot.wait() yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id }) const result = yield* copies.refresh({ projectID: location.project.id }) yield* Effect.logInfo("project copy refresh done", { diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 5ed46d76e13..9572f55a22d 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -126,7 +126,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, list: Effect.fn("Reference.list")(function* () { return Array.from(materialized.values()) }), diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index f567264768a..fa3423c58ac 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -1,7 +1,6 @@ export * as ReferenceGuidance from "./guidance" import { Context, Effect, Layer, Schema } from "effect" -import { PluginBoot } from "../plugin/boot" import { Reference } from "../reference" import { SystemContext } from "../system-context/index" @@ -34,12 +33,10 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const references = yield* Reference.Service return Service.of({ load: Effect.fn("ReferenceGuidance.load")(function* () { - yield* boot.wait() const available = (yield* references.list()) .filter((reference) => reference.description !== undefined) .map((reference) => ({ diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 968933a6b52..68e4ba5e6ac 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -13,7 +13,6 @@ import { Integration } from "../../integration" import { IntegrationConnection } from "../../integration/connection" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" -import { PluginBoot } from "../../plugin/boot" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" @@ -178,11 +177,9 @@ export const locationLayer = Layer.effect( const catalog = yield* Catalog.Service const credentials = yield* Credential.Service const integrations = yield* Integration.Service - const boot = yield* PluginBoot.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. - yield* boot.wait() const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model ? (yield* catalog.model.available()).find( diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 158fb4fab5e..31e3d60c222 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -148,7 +148,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, sources: Effect.fn("SkillV2.sources")(function* () { return state.get().sources }), diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 92fb4c0a629..81a33f01645 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -3,7 +3,6 @@ export * as SkillGuidance from "./guidance" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" -import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { SystemContext } from "../system-context/index" @@ -40,12 +39,10 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service return Service.of({ load: Effect.fn("SkillGuidance.load")(function* (selection) { - yield* boot.wait() const agent = selection.info if (!agent) return SystemContext.empty const permitted = SkillV2.available(yield* skills.list(), agent) diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 1c540e0e97c..ab3457fc181 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -3,7 +3,7 @@ export * as State from "./state" import { Context, Effect, Scope, Semaphore } from "effect" /** - * A replayable transform applied to a draft during rebuild. + * A replayable transform applied to a draft during reload. * * Domain drafts expose readable and writable state while preserving concise * plugin/config code. Transforms may perform Effects before returning. @@ -19,14 +19,14 @@ export type Transform = ( transform: TransformCallback, ) => Effect.Effect -export type Rebuild = () => Effect.Effect +export type Reload = () => Effect.Effect export interface Transformable { readonly transform: Transform - readonly rebuild: Rebuild + readonly reload: Reload } -const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { +const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { defaultValue: () => undefined, }) @@ -34,15 +34,15 @@ export function batch(effect: Effect.Effect) { return Effect.gen(function* () { const current = yield* CurrentBatch if (current) return yield* effect - const rebuilds = new Set() - const result = yield* effect.pipe(Effect.provideService(CurrentBatch, rebuilds)) - yield* Effect.forEach(rebuilds, (rebuild) => rebuild(), { discard: true }) + const reloads = new Set() + const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads)) + yield* Effect.forEach(reloads, (reload) => reload(), { discard: true }) return result }) } export interface Options { - /** Creates the base value for initial state and every scoped-transform rebuild. */ + /** Creates the base value for initial state and every scoped-transform reload. */ readonly initial: () => State /** Wraps mutable state in a domain-specific draft API. */ readonly draft: MakeDraft @@ -54,7 +54,7 @@ export interface Interface extends Transformable { readonly get: () => State /** * Registers and applies a scoped transform. Closing the owning Scope removes - * the transform and rebuilds the materialized state. + * the transform and reloads the materialized state. */ } @@ -78,11 +78,11 @@ export function create(options: Options): Inte const materialize = Effect.fnUntraced(function* () { const next = options.initial() const api = options.draft(next) - for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update")) + for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.reload.update")) yield* commit(next) }) - const rebuild = () => semaphore.withPermit(materialize()) + const reload = () => semaphore.withPermit(materialize()) const result: Interface = { get: () => state, @@ -101,7 +101,7 @@ export function create(options: Options): Inte return Effect.gen(function* () { const batch = yield* CurrentBatch if (batch) { - batch.add(rebuild) + batch.add(reload) return } yield* materialize() @@ -116,13 +116,13 @@ export function create(options: Options): Inte ) yield* Scope.addFinalizer(scope, dispose) const batch = yield* CurrentBatch - if (batch) batch.add(rebuild) - else yield* rebuild() + if (batch) batch.add(reload) + else yield* reload() return { dispose } }), ) }), - rebuild, + reload, } return result } diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 589a99d4622..ce25e785273 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -5,7 +5,6 @@ import { pathToFileURL } from "url" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { FSUtil } from "../fs-util" -import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -58,10 +57,8 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const fs = yield* FSUtil.Service - const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service const permission = yield* PermissionV2.Service - yield* boot.wait() yield* tools .register({ [name]: Tool.make({ diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index f8b8d4eb2ed..ef8d0747d8d 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -50,7 +50,7 @@ describe("AgentV2", () => { ) description = "New description" hidden = false - yield* agent.rebuild() + yield* agent.reload() expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) }), @@ -104,8 +104,12 @@ describe("AgentV2", () => { yield* AgentPlugin.Plugin.effect( host({ agent: agentHost(agent), - location: location({ directory: AbsolutePath.make("/project") }), }), + ).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/project") })), + ), ) const agents = yield* agent.all() diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index bb4b256f893..9890f7e7944 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -259,7 +259,7 @@ describe("CatalogV2", () => { expect((yield* catalog.model.default())?.id).toBe(old) configured = false - yield* catalog.rebuild() + yield* catalog.reload() expect((yield* catalog.model.default())?.id).toBe(newest) }), ) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index bc84d9cdb58..11707c20263 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -42,7 +42,7 @@ Review files`, }) const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect(host({ command })).pipe( + yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe( Effect.provideService( Config.Service, Config.Service.of({ diff --git a/packages/core/test/config/fixtures/plugin/directory-plugin.ts b/packages/core/test/config/fixtures/plugin/directory-plugin.ts new file mode 100644 index 00000000000..e26e12bdac7 --- /dev/null +++ b/packages/core/test/config/fixtures/plugin/directory-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@opencode-ai/plugin/v2/promise" + +export default define({ + id: "directory-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("directory", (agent) => { + agent.description = "Loaded from plugin directory" + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts new file mode 100644 index 00000000000..e6944368c47 --- /dev/null +++ b/packages/core/test/config/plugin.test.ts @@ -0,0 +1,248 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigExternalPlugin", () => { + it.live("resolves and loads a configured Promise plugin with options", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + const document = path.join(import.meta.dir, "config.json") + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: document, + info: decode({ + plugins: [ + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: "Loaded from config" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Loaded from config", + mode: "subagent", + }) + }), + ) + + it.live("loads a configured Effect plugin with options", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: path.join(import.meta.dir, "config.json"), + info: decode({ + plugins: [ + { + package: "../plugin/fixtures/config-effect-plugin.ts", + options: { description: "Effect plugin from config" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({ + description: "Effect plugin from config", + mode: "subagent", + }) + }), + ) + + it.live("ignores invalid plugins and continues loading", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: path.join(import.meta.dir, "config.json"), + info: decode({ + plugins: [ + "../plugin/fixtures/missing-plugin.ts", + "../plugin/fixtures/invalid-plugin.ts", + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: "Loaded after invalid plugins" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Loaded after invalid plugins", + }) + }), + ) + + it.live("installs and resolves npm plugin packages", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const host = yield* PluginHost.make(plugins) + let installed: string | undefined + const npm = Npm.Service.of({ + add: (spec) => + Effect.sync(() => { + installed = spec + return { + directory: import.meta.dir, + entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + } + }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + plugins: [ + { + package: "example-plugin@1.0.0", + options: { description: "Installed from npm" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Installed from npm", + }) + expect(installed).toBe("example-plugin@1.0.0") + }), + ) + + it.live("loads plugin files from config directories", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Directory({ + type: "directory", + path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "directory")).toMatchObject({ + description: "Loaded from plugin directory", + mode: "subagent", + }) + }), + ) +}) + +const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { + for (let attempt = 0; attempt < 100; attempt++) { + const agent = yield* agents.get(AgentV2.ID.make(id)) + if (agent) return agent + yield* Effect.sleep("10 millis") + } + return yield* Effect.die(`Timed out waiting for agent ${id}`) +}) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 19311363edc..12f4a01c788 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -15,11 +15,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* (config: Config.Interface) { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)), - }) + const host = yield* PluginHost.make(plugin) + yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)) }) function required(value: T | undefined): T { diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 2f86714bb2a..e3cbfd557c9 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -36,16 +36,11 @@ describe("ConfigSkillPlugin.Plugin", () => { yield* ConfigSkillPlugin.Plugin.effect( host({ - location: location({ directory }), - path: { ...host().path, home: "/home/test" }, - skill: SkillV2.Service.of({ - transform, - rebuild: () => Effect.void, - sources: () => Effect.succeed(sources), - list: () => Effect.succeed([]), - }), + skill: { transform, reload: () => Effect.void }, }), ).pipe( + Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), + Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), Effect.provideService( Config.Service, Config.Service.of({ diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 0b3e0c8e54f..67e811558e5 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,15 +1,15 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" +import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect" import { Tool } from "@opencode-ai/core/public" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelV2 } from "@opencode-ai/core/model" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -88,7 +88,6 @@ describe("LocationServiceMap", () => { const update = (directory: string) => Effect.gen(function* () { - yield* PluginBoot.Service.use((boot) => boot.wait()) yield* Reference.Service const catalog = yield* Catalog.Service yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) @@ -197,36 +196,24 @@ describe("LocationServiceMap", () => { ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { - const boot = yield* PluginBoot.Service - const catalogUpdated = yield* Deferred.make() - const seen: string[] = [] - yield* boot.add( - define({ - id: "reviewer", - effect: (ctx) => - Effect.gen(function* () { - yield* ctx.event.subscribe("catalog.updated").pipe( - Stream.runForEach(() => Deferred.succeed(catalogUpdated, undefined).pipe(Effect.asVoid)), - Effect.forkScoped({ startImmediately: true }), - ) - yield* ctx.agent.transform((agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code" - item.mode = "subagent" + const plugins = yield* PluginV2.Service + yield* plugins.transform((draft) => + draft.add( + define({ + id: "reviewer", + effect: (ctx) => + ctx.agent + .transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) }) - }) - seen.push((yield* ctx.agent.get("reviewer"))?.description ?? "") - yield* ctx.catalog.transform((catalog) => { - catalog.provider.update("public", (provider) => { - provider.name = "Public provider" - }) - }) - }), - }), + .pipe(Effect.asVoid), + }), + ), ) - yield* Deferred.await(catalogUpdated) - expect(seen).toEqual(["Reviews code"]) expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ description: "Reviews code", mode: "subagent", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index d8fe74336bd..a662ed7ca00 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,127 +1,44 @@ import { describe, expect } from "bun:test" -import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" +import { define } from "@opencode-ai/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" -import { State } from "@opencode-ai/core/state" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" +import { PluginTestLayer } from "./plugin/fixture" -const events = Layer.mock(EventV2.Service)({ - publish: (definition, data) => - Effect.succeed({ - id: EventV2.ID.make("evt_plugin_test"), - type: definition.type, - data, - }), -}) -const plugins = PluginV2.layer.pipe(Layer.provide(events)) - -function state() { - return State.create({ - initial: () => ({ values: [] as string[] }), - draft: (draft) => ({ - add: (value: string) => draft.values.push(value), - }), - }) -} +const it = testEffect(PluginTestLayer) describe("PluginV2", () => { - it.effect("closes plugin-owned scopes when the registry layer finalizes", () => + it.effect("reconciles transformed plugins", () => Effect.gen(function* () { - const values = state() - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + let description = "first" - yield* plugin.add({ - id: PluginV2.ID.make("scoped"), - effect: Effect.gen(function* () { - yield* values.transform((editor) => { - editor.add("scoped") - }) - }), - }) - expect(values.get().values).toEqual(["scoped"]) - - yield* Scope.close(layerScope, Exit.void) - expect(values.get().values).toEqual([]) - }), - ) - - it.effect("batches plugin state rebuilds when the registry layer finalizes", () => - Effect.gen(function* () { - let finalized = 0 - const values = State.create({ - initial: () => ({ values: [] as string[] }), - draft: (draft) => ({ add: (value: string) => draft.values.push(value) }), - finalize: () => Effect.sync(() => finalized++), - }) - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) - - yield* State.batch( - Effect.forEach( - ["first", "second"], - (id) => - plugin.add({ - id: PluginV2.ID.make(id), - effect: values - .transform((editor) => { - editor.add(id) - }) + const registration = yield* plugins.transform((draft) => { + draft.add( + define({ + id: "managed", + effect: (ctx) => + ctx.agent + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = description + }), + ) .pipe(Effect.asVoid), - }), - { discard: true }, - ), - ) - finalized = 0 - - yield* Scope.close(layerScope, Exit.void) - expect(values.get().values).toEqual([]) - expect(finalized).toBe(1) - }), - ) - - it.effect("serializes same-ID additions and leaves one removable attachment", () => - Effect.gen(function* () { - const values = state() - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) - const id = PluginV2.ID.make("shared") - const firstStarted = yield* Deferred.make() - const releaseFirst = yield* Deferred.make() - - const first = yield* plugin - .add({ - id, - effect: Effect.gen(function* () { - yield* values.transform((editor) => { - editor.add("first") - }) - yield* Deferred.succeed(firstStarted, undefined) - yield* Deferred.await(releaseFirst) }), - }) - .pipe(Effect.forkChild) - yield* Deferred.await(firstStarted) + ) + }) - const second = yield* plugin - .add({ - id, - effect: Effect.gen(function* () { - yield* values.transform((editor) => { - editor.add("second") - }) - }), - }) - .pipe(Effect.forkChild({ startImmediately: true })) - expect(values.get().values).toEqual(["first"]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") - yield* Deferred.succeed(releaseFirst, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) - expect(values.get().values).toEqual(["second"]) + description = "second" + yield* plugins.reload() + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") - yield* plugin.remove(id) - expect(values.get().values).toEqual([]) + yield* registration.dispose + expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) }) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index d9d68e98b18..d4e2500c218 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -24,9 +24,13 @@ describe("CommandPlugin.Plugin", () => { const command = yield* CommandV2.Service yield* CommandPlugin.Plugin.effect( host({ - command, - location: location({ directory }, { projectDirectory: project }), + command: { transform: command.transform, reload: command.reload }, }), + ).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory }, { projectDirectory: project })), + ), ) expect(yield* command.get("init")).toMatchObject({ diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 3faa65a6587..4062d37f7a2 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -1,6 +1,3 @@ -import { AgentV2 } from "@opencode-ai/core/agent" -import { Catalog } from "@opencode-ai/core/catalog" -import { CommandV2 } from "@opencode-ai/core/command" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -8,23 +5,13 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" import { PluginV2 } from "@opencode-ai/core/plugin" -import { Reference } from "@opencode-ai/core/reference" import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" -export const PluginTestLayer = Layer.mergeAll( - AgentV2.locationLayer, - CommandV2.locationLayer, - Catalog.locationLayer, - FileSystem.locationLayer, - PluginV2.locationLayer, - Reference.locationLayer, - SkillV2.locationLayer, -).pipe( +export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe( Layer.provideMerge( Layer.mergeAll( Credential.defaultLayer, diff --git a/packages/core/test/plugin/fixtures/config-effect-plugin.ts b/packages/core/test/plugin/fixtures/config-effect-plugin.ts new file mode 100644 index 00000000000..a5f12a113da --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-effect-plugin.ts @@ -0,0 +1,15 @@ +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default define({ + id: "config-effect-plugin", + effect: (ctx) => + ctx.agent + .transform((agents) => { + agents.update("effect-configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), +}) diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts new file mode 100644 index 00000000000..ed53e4b947b --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@opencode-ai/plugin/v2/promise" + +export default define({ + id: "config-promise-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/plugin/fixtures/invalid-plugin.ts b/packages/core/test/plugin/fixtures/invalid-plugin.ts new file mode 100644 index 00000000000..b1c6ea436a5 --- /dev/null +++ b/packages/core/test/plugin/fixtures/invalid-plugin.ts @@ -0,0 +1 @@ +export default {} diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index eb11dc30dd1..02bce652c27 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,120 +1,55 @@ -import type { AISDKHooks, PluginHost } from "@opencode-ai/plugin/v2/effect" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" -import { Effect, Stream } from "effect" +import { Effect } from "effect" -export function host(overrides: Partial = {}): PluginHost { +type Overrides = Partial> + +export function host(overrides: Overrides = {}): PluginContext { return { - aisdk: { - hook: () => Effect.die("unused aisdk.hook"), - }, - agent: { - get: () => Effect.die("unused agent.get"), - default: () => Effect.die("unused agent.default"), - list: () => Effect.die("unused agent.list"), - rebuild: () => Effect.die("unused agent.rebuild"), + options: {}, + agent: overrides.agent ?? { transform: () => Effect.die("unused agent.transform"), + reload: () => Effect.die("unused agent.reload"), }, - catalog: { - provider: { - get: () => Effect.die("unused catalog.provider.get"), - list: () => Effect.die("unused catalog.provider.list"), - available: () => Effect.die("unused catalog.provider.available"), - }, - model: { - get: () => Effect.die("unused catalog.model.get"), - list: () => Effect.die("unused catalog.model.list"), - available: () => Effect.die("unused catalog.model.available"), - default: () => Effect.die("unused catalog.model.default"), - small: () => Effect.die("unused catalog.model.small"), - }, - rebuild: () => Effect.die("unused catalog.rebuild"), + aisdk: overrides.aisdk ?? { + sdk: () => Effect.die("unused aisdk.sdk"), + language: () => Effect.die("unused aisdk.language"), + }, + catalog: overrides.catalog ?? { transform: () => Effect.die("unused catalog.transform"), + reload: () => Effect.die("unused catalog.reload"), }, - command: { - get: () => Effect.die("unused command.get"), - list: () => Effect.die("unused command.list"), - rebuild: () => Effect.die("unused command.rebuild"), + command: overrides.command ?? { transform: () => Effect.die("unused command.transform"), + reload: () => Effect.die("unused command.reload"), }, - event: { - subscribe: () => Stream.die("unused event.subscribe"), - }, - filesystem: { - read: () => Effect.die("unused filesystem.read"), - list: () => Effect.die("unused filesystem.list"), - find: () => Effect.die("unused filesystem.find"), - glob: () => Effect.die("unused filesystem.glob"), - }, - integration: { - get: () => Effect.die("unused integration.get"), - list: () => Effect.die("unused integration.list"), - rebuild: () => Effect.die("unused integration.rebuild"), + integration: overrides.integration ?? { transform: () => Effect.die("unused integration.transform"), + reload: () => Effect.die("unused integration.reload"), }, - location: { - directory: "/unused/location", - project: { directory: "/unused/project" }, + plugin: overrides.plugin ?? { + transform: () => Effect.die("unused plugin.transform"), + reload: () => Effect.die("unused plugin.reload"), }, - npm: { - add: () => Effect.die("unused npm.add"), - }, - path: { - home: "/unused/home", - data: "/unused/data", - cache: "/unused/cache", - config: "/unused/config", - state: "/unused/state", - temp: "/unused/temp", - }, - reference: { - list: () => Effect.die("unused reference.list"), - rebuild: () => Effect.die("unused reference.rebuild"), + reference: overrides.reference ?? { transform: () => Effect.die("unused reference.transform"), + reload: () => Effect.die("unused reference.reload"), }, - skill: { - sources: () => Effect.die("unused skill.sources"), - list: () => Effect.die("unused skill.list"), - rebuild: () => Effect.die("unused skill.rebuild"), + skill: overrides.skill ?? { transform: () => Effect.die("unused skill.transform"), - }, - ...overrides, - } -} - -export function aisdkHost(plugin: PluginV2.Interface): PluginHost["aisdk"] { - return { - hook: (name, callback) => { - if (name === "sdk") { - const run = callback as AISDKHooks["sdk"] - return plugin.hook("aisdk.sdk", (event) => { - const output = { ...event } - const result = run(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), - ) - }) - } - const run = callback as AISDKHooks["language"] - return plugin.hook("aisdk.language", (event) => { - const output = { ...event } - const result = run(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.language = output.language))), - ) - }) + reload: () => Effect.die("unused skill.reload"), }, } } -export function agentHost(agent: AgentV2.Interface): PluginHost["agent"] { +export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { return { - ...host().agent, + reload: agent.reload, transform: (callback) => agent.transform((draft) => callback({ @@ -136,10 +71,9 @@ export function agentHost(agent: AgentV2.Interface): PluginHost["agent"] { } } -export function catalogHost(catalog: Catalog.Interface): PluginHost["catalog"] { +export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { return { - ...host().catalog, - rebuild: catalog.rebuild, + reload: catalog.reload, transform: (callback) => catalog.transform((draft) => callback({ @@ -201,17 +135,9 @@ export function catalogHost(catalog: Catalog.Interface): PluginHost["catalog"] { } } -export function integrationHost(integration: Integration.Interface): PluginHost["integration"] { - const info = (value: Integration.Info) => ({ - id: value.id, - name: value.name, - methods: value.methods.map(method), - connections: value.connections.map((item) => ({ ...item })), - }) +export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { return { - get: (id) => integration.get(Integration.ID.make(id)).pipe(Effect.map((value) => value && info(value))), - list: () => integration.list().pipe(Effect.map((items) => items.map(info))), - rebuild: integration.rebuild, + reload: integration.reload, transform: (callback) => integration.transform((draft) => callback({ diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index c872b6fe65e..4c3071c7748 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,15 +1,13 @@ import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer, Stream } from "effect" +import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" -import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" import { Policy } from "@opencode-ai/core/policy" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -22,21 +20,13 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) -const plugins = PluginV2.layer.pipe(Layer.provide(events)) const policy = Policy.layer.pipe(Layer.provide(locationLayer)) const connections = Credential.defaultLayer.pipe(Layer.fresh) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) const catalog = Catalog.layer.pipe( - Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), -) -const layer = Layer.mergeAll( - catalog.pipe(Layer.provide(connections)), - integrations, - connections, - events, - locationLayer, - plugins, + Layer.provide(Layer.mergeAll(events, locationLayer, policy, connections, integrations)), ) +const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer) const it = testEffect(layer) describe("ModelsDevPlugin", () => { @@ -58,7 +48,6 @@ describe("ModelsDevPlugin", () => { yield* ModelsDevPlugin.effect( host({ catalog: catalogHost(catalog), - event: { subscribe: () => Stream.never }, integration: integrationHost(integrations), }), ) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts new file mode 100644 index 00000000000..41a66419464 --- /dev/null +++ b/packages/core/test/plugin/promise.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { PluginPromise } from "@opencode-ai/core/plugin/promise" +import { define } from "@opencode-ai/plugin/v2/promise" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +describe("fromPromise", () => { + it.effect("loads a promise plugin and registers a transform hook", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-example", + setup: async (ctx) => { + expect(ctx.options.mode).toBe("strict") + await ctx.agent.transform((draft) => { + draft.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect({ ...host, options: { mode: "strict" } }) + + expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }), + ) + + it.effect("disposes a hook registration on request", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-dispose", + setup: async (ctx) => { + const registration = await ctx.agent.transform((draft) => { + draft.update("temp", (item) => { + item.description = "temporary" + }) + }) + await registration.dispose() + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect(host) + + expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index 5fb8b16bf00..cda7b23e2d9 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { createAlibaba } from "@ai-sdk/alibaba" import { Effect } from "effect" @@ -13,27 +14,25 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AlibabaPlugin.effect(host) }) describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/alibaba", - options: { name: "alibaba" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "alibaba" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -41,19 +40,16 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "alibaba" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "alibaba" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -61,19 +57,16 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/alibaba", - options: { name: "custom-alibaba", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "custom-alibaba", apiKey: "test" }, + }) const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen") const actual = result.sdk?.languageModel("qwen") expect(actual?.provider).toBe(expected.provider) @@ -84,12 +77,13 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() const item = new ModelV2.Info({ ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" }, }) - const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) + const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} }) const language = result.sdk?.languageModel(item.api.id) expect(language?.modelId).toBe("qwen-plus") expect(language?.provider).toBe("alibaba.chat") diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index 1a2512485b0..b6ef65f1aef 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AmazonBedrockPlugin.effect(host) }) function required(value: T | undefined): T { @@ -109,25 +111,22 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://base.example", - endpoint: "https://endpoint.example", - region: "us-east-1", - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + endpoint: "https://endpoint.example", + region: "us-east-1", }, - {}, - ) + }) expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example") }), ), @@ -137,24 +136,21 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://base.example", - region: "us-east-1", - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + region: "us-east-1", }, - {}, - ) + }) expect(bedrockBaseURL(result.sdk)).toBe("https://base.example") }), ), @@ -174,23 +170,20 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(result.sdk).toBeDefined() expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") }), @@ -201,19 +194,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock", region: "eu-west-1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock", region: "eu-west-1" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") }), ), @@ -223,19 +213,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") }), ), @@ -245,19 +232,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") }), ), @@ -267,27 +251,24 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "option-token", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token") expect(headers).toEqual(["Bearer option-token"]) @@ -299,27 +280,24 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "option-token", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token") expect(headers).toEqual(["Bearer env-token"]) @@ -331,28 +309,25 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), - api: { - id: ModelV2.ID.make("openai.gpt-5.5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, - }), - package: "@ai-sdk/amazon-bedrock/mantle", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", - region: "us-east-2", + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", }, + }), + package: "@ai-sdk/amazon-bedrock/mantle", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + region: "us-east-2", }, - {}, - ) + }) const language = result.sdk.responses("openai.gpt-5.5") expect(openAIUrl(language, "/responses", "openai.gpt-5.5")).toBe( "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses", @@ -364,40 +339,33 @@ describe("AmazonBedrockPlugin", () => { it.effect("selects Mantle APIs without Bedrock cross-region prefixes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), - api: { - id: ModelV2.ID.make("openai.gpt-5.5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, - }), - sdk: fakeSelectorSdk(calls), - options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), - api: { - id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, - }), - sdk: fakeSelectorSdk(calls), - options: { region: "us-east-1" }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, + }), + sdk: fakeSelectorSdk(calls), + options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), + api: { + id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, + }), + sdk: fakeSelectorSdk(calls), + options: { region: "us-east-1" }, + }) expect(calls).toEqual(["responses:openai.gpt-5.5", "chat:openai.gpt-oss-safeguard-120b"]) }), ) @@ -405,23 +373,20 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/anthropic", - }, - }), - package: "@ai-sdk/amazon-bedrock/anthropic", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/anthropic", + }, + }), + package: "@ai-sdk/amazon-bedrock/anthropic", + options: { name: "amazon-bedrock" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -438,30 +403,27 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", { body: "{}", @@ -476,72 +438,53 @@ describe("AmazonBedrockPlugin", () => { it.effect("applies legacy cross-region inference prefixes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "ap-northeast-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "ap-southeast-2" }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-northeast-1" }, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-southeast-2" }, + }) expect(calls).toEqual([ "languageModel:us.anthropic.claude-sonnet-4-5", "languageModel:eu.anthropic.claude-sonnet-4-5", @@ -556,20 +499,17 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"]) }), ), @@ -578,6 +518,7 @@ describe("AmazonBedrockPlugin", () => { it.effect("applies the full legacy cross-region prefix matrix", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const cases = [ { region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" }, @@ -647,18 +588,14 @@ describe("AmazonBedrockPlugin", () => { ] yield* addPlugin() for (const item of cases) { - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), - api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: item.region }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: item.region }, + }) } expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`)) }), @@ -667,20 +604,17 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores non-Bedrock providers for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index ba3a33915b0..8cf8d155557 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AnthropicPlugin.effect(host) }) function required(value: T | undefined): T { @@ -59,19 +61,16 @@ describe("AnthropicPlugin", () => { it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, - }), - package: "@ai-sdk/anthropic", - options: { name: "custom-anthropic", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), + package: "@ai-sdk/anthropic", + options: { name: "custom-anthropic", apiKey: "test" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic") }), ) @@ -79,19 +78,16 @@ describe("AnthropicPlugin", () => { it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, - }), - package: "@ai-sdk/anthropic", - options: { name: "anthropic", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), + package: "@ai-sdk/anthropic", + options: { name: "anthropic", apiKey: "test" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic") }), ) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 2c1c7ec8788..08e878801a7 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AzureCognitiveServicesPlugin.effect(host) }) function required(value: T | undefined): T { @@ -114,20 +116,17 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("selects chat only for completion URLs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -135,32 +134,25 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("uses the legacy Azure selector order and provider guard", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - const ignored = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + const ignored = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) expect(ignored.language).toBeUndefined() }), @@ -169,51 +161,34 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("falls back from responses to messages, chat, then languageModel", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("azure-cognitive-services"), - ModelV2.ID.make("messages-deployment"), - ), - api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), - api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("azure-cognitive-services"), - ModelV2.ID.make("language-deployment"), - ), - api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), + api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: sdk.languageModel }, + options: {}, + }) expect(calls).toEqual([ "messages:messages-deployment", "chat:chat-deployment", diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 10c2a005dcc..b479435e5e7 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AzurePlugin.effect(host) }) function required(value: T | undefined): T { @@ -142,19 +144,16 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/azure", - options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -163,21 +162,17 @@ describe("AzurePlugin", () => { it.effect("rejects missing resourceName when baseURL is not configured", () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const exit = yield* plugin - .trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/azure", - options: { name: "azure" }, - }, - {}, - ) + const exit = yield* aisdk + .runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure" }, + }) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), @@ -187,20 +182,17 @@ describe("AzurePlugin", () => { it.effect("selects chat only for completion URLs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -208,20 +200,17 @@ describe("AzurePlugin", () => { it.effect("selects chat from per-call useCompletionUrls", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -229,21 +218,18 @@ describe("AzurePlugin", () => { it.effect("ignores model useCompletionUrls when per-call option is unset", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - request: { headers: {}, body: { useCompletionUrls: true } }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + request: { headers: {}, body: { useCompletionUrls: true } }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) }), ) @@ -251,32 +237,25 @@ describe("AzurePlugin", () => { it.effect("uses the legacy Azure selector order and provider guard", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - const ignored = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + const ignored = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) expect(ignored.language).toBeUndefined() }), @@ -285,36 +264,29 @@ describe("AzurePlugin", () => { it.effect("falls back through the legacy Azure selector order", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const make = (method: string) => (id: string) => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), - api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), - api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: make("languageModel") }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: make("languageModel") }, + options: {}, + }) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) }), ) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index 5501ad39e3f..f741041e545 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: CerebrasPlugin.id, effect: CerebrasPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CerebrasPlugin.effect(host) }) void mock.module("@ai-sdk/cerebras", () => ({ @@ -59,26 +61,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("custom-cerebras"), - ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/cerebras", - options: { name: "custom-cerebras", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/cerebras", + options: { name: "custom-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([{ name: "custom-cerebras", apiKey: "test" }]) expect(result.sdk.languageModel("llama-4-scout-17b-16e-instruct").provider).toBe("custom-cerebras") }), @@ -88,26 +87,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("custom-cerebras"), - ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/cerebras", - options: { name: "configured-cerebras", apiKey: "test" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/cerebras", + options: { name: "configured-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([{ name: "configured-cerebras", apiKey: "test" }]) }), ) @@ -116,26 +112,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("custom-cerebras"), - ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/groq", - options: { name: "custom-cerebras", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/groq", + options: { name: "custom-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([]) expect(result.sdk).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 31ce4448f01..5bc8a3d5ff1 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -12,8 +13,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CloudflareAIGatewayPlugin.effect(host) }) function withEnv(vars: Record, fx: () => Effect.Effect) { @@ -111,19 +113,16 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() }), ), @@ -134,27 +133,24 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - metadata: { invoked_by: "test", project: "opencode" }, - cacheTtl: 300, - cacheKey: "cache-key", - skipCache: true, - collectLog: false, - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + metadata: { invoked_by: "test", project: "opencode" }, + cacheTtl: 300, + cacheKey: "cache-key", + skipCache: true, + collectLog: false, }, - {}, - ) + }) expect(aiGatewayCalls).toHaveLength(1) expect(aiGatewayCalls[0]).toEqual({ @@ -181,25 +177,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - headers: { - "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + headers: { + "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), }, }, - {}, - ) + }) expect(aiGatewayCalls[0]?.options).toMatchObject({ metadata: { invoked_by: "header", project: "opencode" }, @@ -213,25 +206,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gateway: "auth-gateway", - apiKey: "auth-token", - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gateway: "auth-gateway", + apiKey: "auth-token", }, - {}, - ) + }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "env-account", @@ -253,25 +243,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gatewayId: "auth-gateway", - apiKey: "auth-token", - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gatewayId: "auth-gateway", + apiKey: "auth-token", }, - {}, - ) + }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "auth-account", @@ -287,20 +274,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) }), @@ -312,20 +296,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -338,20 +319,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -370,20 +348,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -396,27 +371,24 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("anthropic/claude-sonnet-4-5"), - ), - api: { - id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, @@ -434,20 +406,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index f6da837d8ba..c911f740f5e 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CloudflareWorkersAIPlugin.effect(host) }) function required(value: T | undefined): T { @@ -81,6 +83,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { @@ -89,18 +92,14 @@ describe("CloudflareWorkersAIPlugin", () => { ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, - }, - {}, - ) + const sdk = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, + }) expect(provider.api).toEqual({ type: "aisdk", package: "test-provider", @@ -134,24 +133,21 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://proxy.example/v1", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, + }) expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions") }), ), @@ -181,29 +177,26 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://proxy.example/v1", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - apiKey: "auth-key", - baseURL: "https://proxy.example/v1", - headers: { custom: "header" }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + apiKey: "auth-key", + baseURL: "https://proxy.example/v1", + headers: { custom: "header" }, }, - {}, - ) + }) const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk))) expect(headers.authorization).toBe("Bearer env-key") expect(headers.custom).toBe("header") @@ -216,27 +209,24 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", }, - {}, - ) + }) expect(cloudflareURL(result.sdk)).toBe( "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions", ) @@ -247,20 +237,17 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("selects languageModel with the API model ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(result.language).toBeDefined() expect(calls).toEqual(["languageModel:@cf/api-model"]) }), @@ -270,24 +257,21 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/anthropic", - url: "https://proxy.example/v1", - }, - }), - package: "@ai-sdk/anthropic", - options: { name: "cloudflare-workers-ai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/anthropic", + url: "https://proxy.example/v1", + }, + }), + package: "@ai-sdk/anthropic", + options: { name: "cloudflare-workers-ai" }, + }) expect(result.sdk).toBeUndefined() }), ), diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index f0d09b8411e..0ee465d6f95 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: CoherePlugin.id, effect: CoherePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CoherePlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -48,34 +50,27 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cohere" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cohere" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/cohere", - options: { name: "cohere" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "cohere" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -83,19 +78,16 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), - api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/cohere", - options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, + }) expect(cohereOptions.at(-1)).toEqual({ name: "custom-cohere", @@ -109,21 +101,18 @@ describe("CoherePlugin", () => { it.effect("leaves language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, - }), - sdk, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }) expect(result.language).toBeUndefined() expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index db7dd104297..d428b9b7e08 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -14,8 +15,9 @@ const deepinfraLanguageModels: string[] = [] const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* DeepInfraPlugin.effect(host) }) void mock.module("@ai-sdk/deepinfra", () => ({ @@ -41,19 +43,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -62,19 +61,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "custom-deepinfra", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "custom-deepinfra", apiKey: "test" }, + }) expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat") expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }]) }), @@ -84,19 +80,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra", apiKey: "test" }, + }) expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat") expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }]) }), @@ -106,6 +99,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() const packages = [ "unmatched-package", @@ -114,33 +108,25 @@ describe("DeepInfraPlugin", () => { ] yield* Effect.forEach(packages, (item) => Effect.gen(function* () { - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: item, - options: { name: "deepinfra" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: item, + options: { name: "deepinfra" }, + }) expect(ignored.sdk).toBeUndefined() }), ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) expect(result.sdk).toBeDefined() expect(deepinfraOptions).toEqual([{ name: "deepinfra" }]) }), @@ -150,31 +136,21 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdkEvent = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("deepinfra"), - ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), - ), - api: { - id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), - type: "aisdk", - package: "@ai-sdk/deepinfra", - }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }, - {}, - ) - const result = yield* plugin.trigger( - "aisdk.language", - { model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }, - {}, - ) + const sdkEvent = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct")), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + type: "aisdk", + package: "@ai-sdk/deepinfra", + }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) + const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }) const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.provider).toBe("deepinfra.chat") expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 150c9ea84d6..548b1a92e99 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -17,7 +17,7 @@ import { PluginTestLayer } from "./fixture" const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProviderPath = fileURLToPath(fixtureProvider) const it = testEffect(PluginTestLayer) -const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer))) +const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer))) function npmEntrypoint(entrypoint?: string) { return Npm.Service.of({ @@ -29,11 +29,8 @@ function npmEntrypoint(entrypoint?: string) { const addPlugin = Effect.fn(function* (npm?: Npm.Interface) { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ - id: DynamicProviderPlugin.id, - effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host), - }) + const host = yield* PluginHost.make(plugin) + yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service))) }) function tempEntrypoint(source: string) { @@ -51,20 +48,16 @@ function tempEntrypoint(source: string) { describe("DynamicProviderPlugin", () => { it.effect("creates an SDK from a provider factory export", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, - }), - package: fixtureProvider, - options: { name: "custom", marker: "dynamic" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + }) expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" }) expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } }) }), @@ -72,68 +65,56 @@ describe("DynamicProviderPlugin", () => { it.effect("does not override an SDK already supplied by an earlier plugin", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const sdk = { marker: "existing" } yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, - }), - package: fixtureProvider, - options: { name: "custom", marker: "dynamic" }, - }, - { sdk }, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + sdk, + }) expect(result.sdk).toBe(sdk) }), ) it.effect("injects the provider ID as the SDK factory name", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, - }), - package: fixtureProvider, - options: { name: "custom-provider", marker: "dynamic" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom-provider", marker: "dynamic" }, + }) expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" }) }), ) it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(npmEntrypoint(fixtureProviderPath)) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, - }), - package: "fixture-provider", - options: { name: "npm-provider", marker: "npm" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, + }), + package: "fixture-provider", + options: { name: "npm-provider", marker: "npm" }, + }) expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } }) }), ) itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin(npmEntrypoint()) const exit = yield* aisdk @@ -151,7 +132,6 @@ describe("DynamicProviderPlugin", () => { itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const exit = yield* aisdk diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 619e184d83e..9179a0b042e 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GatewayPlugin.effect(host) }) mock.module("@ai-sdk/gateway", () => ({ @@ -38,19 +40,16 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/gateway", - options: { name: "gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "gateway" }, + }) expect(result.sdk).toBeDefined() expect(gatewayCalls).toHaveLength(1) }), @@ -60,24 +59,21 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), - api: { - id: ModelV2.ID.make("anthropic/claude-sonnet-4"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/gateway", - options: { name: "vercel", apiKey: "test-key" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel", apiKey: "test-key" }, + }) expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }]) expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel") @@ -88,35 +84,28 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() for (const modelID of vercelGatewayModels) { - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/vercel", - options: { name: "vercel" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/vercel", + options: { name: "vercel" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/gateway", - options: { name: "vercel" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel" }, + }) expect(result.sdk).toBeDefined() } diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index b8f615f9337..03d644132d2 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GithubCopilotPlugin.effect(host) }) function required(value: T | undefined): T { @@ -40,31 +42,24 @@ describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "github-copilot" }, - }, - {}, - ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/github-copilot", - options: { name: "github-copilot" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "github-copilot" }, + }) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/github-copilot", + options: { name: "github-copilot" }, + }) expect(ignored.sdk).toBeUndefined() expect(result.sdk).toBeDefined() }), @@ -73,20 +68,17 @@ describe("GithubCopilotPlugin", () => { it.effect("selects languageModel when responses and chat are absent", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4"]) }), ) @@ -94,20 +86,17 @@ describe("GithubCopilotPlugin", () => { it.effect("selects languageModel with the API model ID when responses and chat are absent", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4"]) }), ) @@ -115,68 +104,49 @@ describe("GithubCopilotPlugin", () => { it.effect("uses responses for gpt-5 models except gpt-5-mini", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), - api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), - api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), - api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), - api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), + api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([ "responses:gpt-5", "responses:gpt-5.1-codex", @@ -190,44 +160,33 @@ describe("GithubCopilotPlugin", () => { it.effect("uses the API model ID when selecting responses or chat", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), - api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"]) }), ) @@ -265,20 +224,17 @@ describe("GithubCopilotPlugin", () => { it.effect("ignores non-Copilot providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index 1940bba937d..f7c75a7d1d2 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GitLabPlugin.id, effect: GitLabPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GitLabPlugin.effect(host) }) function withEnv(vars: Record, effect: () => Effect.Effect) { @@ -63,19 +65,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - }), - package: "gitlab-ai-provider", - options: { name: "gitlab" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }) expect(gitlabSDKOptions).toHaveLength(1) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://gitlab.com") expect(gitlabSDKOptions[0].apiKey).toBe("env-token") @@ -103,19 +102,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - }), - package: "gitlab-ai-provider", - options: { name: "gitlab" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") }), ), @@ -131,31 +127,28 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - }), - package: "gitlab-ai-provider", - options: { - name: "gitlab", - instanceUrl: "https://configured.gitlab.example", - apiKey: "configured-token", - aiGatewayHeaders: { - "anthropic-beta": "configured-beta", - "x-gitlab-test": "1", - }, - featureFlags: { - duo_agent_platform: false, - custom_flag: true, - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { + name: "gitlab", + instanceUrl: "https://configured.gitlab.example", + apiKey: "configured-token", + aiGatewayHeaders: { + "anthropic-beta": "configured-beta", + "x-gitlab-test": "1", + }, + featureFlags: { + duo_agent_platform: false, + custom_flag: true, }, }, - {}, - ) + }) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://configured.gitlab.example") expect(gitlabSDKOptions[0].apiKey).toBe("configured-token") expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({ @@ -175,19 +168,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai", - options: { name: "gitlab" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "gitlab" }, + }) expect(result.sdk).toBeUndefined() expect(gitlabSDKOptions).toHaveLength(0) }), @@ -196,30 +186,27 @@ describe("GitLabPlugin", () => { it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), - api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, - request: { - headers: {}, - body: { workflowRef: "ref", workflowDefinition: "definition" }, - }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, + request: { + headers: {}, + body: { workflowRef: "ref", workflowDefinition: "definition" }, }, - options: { featureFlags: { configured: true } }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([ ["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: "definition" }], ]) @@ -234,26 +221,23 @@ describe("GitLabPlugin", () => { it.effect("uses exact static workflow model ids when the provider recognizes them", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), - api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } }, - options: { featureFlags: { configured: true } }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([ ["duo-workflow-exact", { featureFlags: { configured: true }, workflowDefinition: undefined }], ]) @@ -264,30 +248,27 @@ describe("GitLabPlugin", () => { it.effect("uses provider feature flags instead of request feature flags", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), - api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, - request: { - headers: {}, - body: { featureFlags: { request_flag: true } }, - }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, + request: { + headers: {}, + body: { featureFlags: { request_flag: true } }, }, - options: { featureFlags: { configured: true } }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: undefined }]]) }), ) @@ -295,33 +276,30 @@ describe("GitLabPlugin", () => { it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - request: { headers: { h: "v" }, body: {} }, - }), - sdk: { - workflowChat: () => undefined, - agenticChat: (id: string, options: unknown) => { - const selected = options as { - aiGatewayHeaders?: Record - featureFlags?: Record - } - calls.push([ - id, - { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, - ]) - }, + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + request: { headers: { h: "v" }, body: {} }, + }), + sdk: { + workflowChat: () => undefined, + agenticChat: (id: string, options: unknown) => { + const selected = options as { + aiGatewayHeaders?: Record + featureFlags?: Record + } + calls.push([ + id, + { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, + ]) }, - options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, }, - {}, - ) + options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, + }) expect(calls).toEqual([ ["claude", { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }], ]) diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index fe9b0b0d958..511cb7ac056 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: definition.id, effect: definition.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* definition.effect(host) }) function withEnv(vars: Record, effect: () => Effect.Effect) { @@ -111,22 +113,19 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("google-vertex-anthropic"), - ModelV2.ID.make("claude-sonnet-4-5"), - ), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models", ) @@ -140,22 +139,19 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("google-vertex-anthropic"), - ModelV2.ID.make("claude-sonnet-4-5"), - ), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models", ) @@ -166,19 +162,16 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models", ) @@ -188,19 +181,16 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe("https://proxy.example/v1") }), ) @@ -208,32 +198,25 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin) - const sdkResult = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "us" }, - }, - {}, - ) - const languageResult = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, - }), - sdk: sdkResult.sdk, - options: {}, - }, - {}, - ) + const sdkResult = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "us" }, + }) + const languageResult = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: sdkResult.sdk, + options: {}, + }) const language = languageResult.language as unknown as { config: { baseURL: string }; modelId: string } expect(language.config.baseURL).toBe( "https://aiplatform.us.rep.googleapis.com/v1/projects/project/locations/us/publishers/anthropic/models", @@ -245,23 +228,17 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin(GoogleVertexAnthropicPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("google-vertex-anthropic"), - ModelV2.ID.make(" claude-sonnet-4-5 "), - ), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: selector(calls) }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4-5"]) }), ) @@ -269,20 +246,17 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("ignores non Vertex Anthropic providers for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: selector(calls) }, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index f5f62f8df79..b66ce143568 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -16,8 +17,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GoogleVertexPlugin.id, effect: GoogleVertexPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GoogleVertexPlugin.effect(host) }) function required(value: T | undefined): T { @@ -154,6 +156,7 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { @@ -166,22 +169,18 @@ describe("GoogleVertexPlugin", () => { ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/google-vertex", - }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google-vertex" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }) expect(provider.request.body.project).toBe("vertex-project") expect(provider.api).toEqual({ @@ -293,23 +292,20 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/google-vertex", - }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google-vertex" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }) expect(vertexOptions).toHaveLength(1) expect(vertexOptions[0].project).toBe("env-project") expect(vertexOptions[0].location).toBe("env-location") @@ -323,8 +319,9 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.hook("aisdk.sdk", (evt) => + yield* aisdk.hook.sdk((evt) => Effect.promise(async () => { if (evt.model.providerID !== "google-vertex") return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -345,22 +342,18 @@ describe("GoogleVertexPlugin", () => { yield* Effect.acquireUseRelease( Effect.void, () => - plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "google-vertex" }, - }, - {}, - ), + aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "google-vertex" }, + }), () => Effect.sync(() => { ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch @@ -377,20 +370,17 @@ describe("GoogleVertexPlugin", () => { it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), - api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:gemini-2.5-pro"]) }), ) diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index 1197957f5a9..620f01e9ce3 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -12,27 +13,25 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GooglePlugin.effect(host) }) describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), - api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, - }), - package: "@ai-sdk/google", - options: { name: "custom-google", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }) expect(result.sdk).toBeDefined() expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google") }), @@ -41,19 +40,16 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), - api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -61,28 +57,21 @@ describe("GooglePlugin", () => { it.effect("uses default languageModel loading with provider ID parity", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdkEvent = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, - }), - package: "@ai-sdk/google", - options: { name: "custom-google", apiKey: "test" }, - }, - {}, - ) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: sdkEvent.model, - sdk: sdkEvent.sdk, - options: sdkEvent.options, - }, - {}, - ) + const sdkEvent = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }) + const result = yield* aisdk.runLanguage({ + model: sdkEvent.model, + sdk: sdkEvent.sdk, + options: sdkEvent.options, + }) const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("gemini-api") expect(language.provider).toBe("custom-google") diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index dbc97205b26..4f469e95c0d 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { createGroq } from "@ai-sdk/groq" import { Effect } from "effect" @@ -13,27 +14,25 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GroqPlugin.effect(host) }) describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, - }), - package: "@ai-sdk/groq", - options: { name: "groq" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "groq" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -41,19 +40,16 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "groq" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "groq" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -61,19 +57,16 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, - }), - package: "@ai-sdk/groq/compat", - options: { name: "groq" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq/compat", + options: { name: "groq" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -81,19 +74,16 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, - }), - package: "@ai-sdk/groq", - options: { name: "custom-groq", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "custom-groq", apiKey: "test" }, + }) const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters[0] & { name: string }).languageModel("llama") @@ -106,26 +96,23 @@ describe("GroqPlugin", () => { it.effect("uses the default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { name: string }) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), - api: { - id: ModelV2.ID.make("llama-api"), - type: "aisdk", - package: "@ai-sdk/groq", - }, - }), - sdk, - options: { name: "groq", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), + api: { + id: ModelV2.ID.make("llama-api"), + type: "aisdk", + package: "@ai-sdk/groq", + }, + }), + sdk, + options: { name: "groq", apiKey: "test" }, + }) const language = result.language ?? sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("llama-api") expect(language.provider).toBe("groq.chat") diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 5e7a7c2d2bb..1df0fd31568 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -13,8 +13,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + yield* KiloPlugin.effect(host) }) describe("KiloPlugin", () => { diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 0fc22c5235d..d7f9d0d73d4 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -14,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + const integration = yield* Integration.Service + yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) }) describe("LLMGatewayPlugin", () => { diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index f09e0e62c70..0544d5f50cd 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" @@ -13,27 +14,25 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: MistralPlugin.id, effect: MistralPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* MistralPlugin.effect(host) }) describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/mistral", - options: { name: "mistral" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -41,19 +40,16 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "mistral" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "mistral" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -61,25 +57,22 @@ describe("MistralPlugin", () => { it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const providers: string[] = [] yield* addPlugin() - yield* plugin.hook("aisdk.sdk", (event) => + yield* aisdk.hook.sdk((event) => Effect.sync(() => { providers.push(event.sdk.languageModel("mistral-large").provider) }), ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/mistral", - options: { name: "mistral" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }) expect(result.sdk).toBeDefined() expect(providers).toEqual(["mistral.chat"]) }), @@ -88,25 +81,22 @@ describe("MistralPlugin", () => { it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const providers: string[] = [] yield* addPlugin() - yield* plugin.hook("aisdk.sdk", (event) => + yield* aisdk.hook.sdk((event) => Effect.sync(() => { providers.push(event.sdk.languageModel("mistral-large").provider) }), ) - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/mistral", - options: { name: "custom-mistral" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "custom-mistral" }, + }) expect(providers).toEqual(["mistral.chat"]) }), ) @@ -114,6 +104,7 @@ describe("MistralPlugin", () => { it.effect("leaves Mistral language selection on the default sdk.languageModel(api.id) path", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = { languageModel: (id: string) => { @@ -122,18 +113,14 @@ describe("MistralPlugin", () => { }, } yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - sdk, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }) const language = result.language ?? sdk.languageModel(result.model.api.id) expect(calls).toEqual(["languageModel:mistral-large"]) expect(language).toBeDefined() diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index ee16e5a2bea..a1c05df3359 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -13,8 +13,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + yield* NvidiaPlugin.effect(host) }) describe("NvidiaPlugin", () => { diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index c0601c2ba33..74adb0ef437 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -12,39 +13,33 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* OpenAICompatiblePlugin.effect(host) }) describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const defaulted = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom" }, - }, - {}, - ) - const disabled = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom", includeUsage: false }, - }, - {}, - ) + const defaulted = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom" }, + }) + const disabled = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom", includeUsage: false }, + }) expect(defaulted.options.includeUsage).toBe(true) expect(disabled.options.includeUsage).toBe(false) }), @@ -53,19 +48,16 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", - options: { name: "custom" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", + options: { name: "custom" }, + }) expect(result.options.includeUsage).toBe(true) }), ) @@ -73,55 +65,42 @@ describe("OpenAICompatiblePlugin", () => { it.effect("uses the provider ID as the OpenAI-compatible provider name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const observed: string[] = [] yield* addPlugin() - yield* plugin.hook("aisdk.sdk", (event) => + yield* aisdk.hook.sdk((event) => Effect.sync(() => { observed.push(event.sdk.languageModel("model").provider) }), ) - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom-provider", baseURL: "https://example.com/v1" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom-provider", baseURL: "https://example.com/v1" }, + }) expect(observed).toEqual(["custom-provider.chat"]) }), ) it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const sentinel = { languageModel: (modelID: string) => ({ modelID }) } - yield* plugin.add({ - id: PluginV2.ID.make("sentinel"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - evt.sdk = sentinel - }), - }), + yield* aisdk.hook.sdk((event) => { + event.sdk = sentinel }) yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai" }, + }) expect(result.sdk).toBe(sentinel) }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index a9911d2cc8a..867de3a4810 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -15,12 +16,10 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) const integrations = yield* Integration.Service - yield* plugin.add({ - id: OpenAIPlugin.id, - effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)), - }) + yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) }) function required(value: T | undefined): T { @@ -63,19 +62,16 @@ describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai", - options: { name: "custom-openai", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "custom-openai", apiKey: "test" }, + }) expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses") }), ) @@ -83,19 +79,16 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "openai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openai" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -103,20 +96,17 @@ describe("OpenAIPlugin", () => { it.effect("uses the Responses API for language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:gpt-5"]) expect(result.language).toBeDefined() }), @@ -125,20 +115,17 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 750b71463cf..b634d0fee3a 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -14,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + const integration = yield* Integration.Service + yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) }) function required(value: T | undefined): T { diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 5761827c4ea..d05f5721c55 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* OpenRouterPlugin.effect(host) }) describe("OpenRouterPlugin", () => { @@ -47,34 +49,27 @@ describe("OpenRouterPlugin", () => { it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "openrouter" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openrouter" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@openrouter/ai-sdk-provider", - options: { name: "custom" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@openrouter/ai-sdk-provider", + options: { name: "custom" }, + }) expect(result.sdk).toBeDefined() }), ) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index eeb00093ebf..7044b55e5ae 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* PerplexityPlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -34,19 +36,16 @@ describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/perplexity", - options: { name: "perplexity" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -54,19 +53,16 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/perplexity-compatible", - options: { name: "perplexity" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity-compatible", + options: { name: "perplexity" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -74,19 +70,16 @@ describe("PerplexityPlugin", () => { it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/perplexity", - options: { name: "perplexity" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }) expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) @@ -94,19 +87,16 @@ describe("PerplexityPlugin", () => { it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/perplexity", - options: { name: "custom-perplexity" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "custom-perplexity" }, + }) expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) @@ -114,20 +104,17 @@ describe("PerplexityPlugin", () => { it.effect("leaves Perplexity language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 6892aaf6aa1..ab48409902f 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -19,8 +20,9 @@ const npm = Npm.Service.of({ const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* SapAICorePlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm)) }) function withEnv(vars: Record, effect: () => Effect.Effect) { @@ -58,16 +60,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("sap-ai-core"), - package: fixtureProvider, - options: { name: "sap-ai-core", serviceKey: "service-key" }, - }, - {}, - ) + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "service-key" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBe("service-key") expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) }), @@ -84,16 +83,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("sap-ai-core"), - package: fixtureProvider, - options: { name: "sap-ai-core", serviceKey: "option-service-key" }, - }, - {}, - ) + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "option-service-key" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBe("env-service-key") expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) }), @@ -106,12 +102,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } }, - {}, - ) + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(sdk.sdk.options).toEqual({}) }), @@ -121,13 +118,14 @@ describe("SapAICorePlugin", () => { it.effect("uses the callable SDK for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { languageModel() { throw new Error("SAP AI Core should call the SDK directly") }, }) - const language = yield* plugin.trigger("aisdk.language", { model: model("sap-ai-core"), sdk, options: {} }, {}) + const language = yield* aisdk.runLanguage({ model: model("sap-ai-core"), sdk, options: {} }) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) }), ) @@ -138,27 +136,20 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("openai"), - package: fixtureProvider, - options: { name: "openai", serviceKey: "service-key" }, + const sdk = yield* aisdk.runSDK({ + model: model("openai"), + package: fixtureProvider, + options: { name: "openai", serviceKey: "service-key" }, + }) + const language = yield* aisdk.runLanguage({ + model: model("openai"), + sdk: () => { + throw new Error("SAP AI Core should ignore other providers") }, - {}, - ) - const language = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai"), - sdk: () => { - throw new Error("SAP AI Core should ignore other providers") - }, - options: {}, - }, - {}, - ) + options: {}, + }) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(sdk.sdk).toBeUndefined() expect(language.language).toBeUndefined() diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index ca839fff519..a2b6371a3dd 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* SnowflakeCortexPlugin.effect(host) }) function withEnv(vars: Record, effect: () => Effect.Effect) { @@ -50,19 +52,16 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), - api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai", - options: { name: "openai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "openai" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -71,19 +70,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -93,23 +89,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "snowflake-cortex", - baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", - apiKey: "options-pat", - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + apiKey: "options-pat", }, - {}, - ) + }) expect(result.sdk).toBeDefined() }), ), @@ -119,19 +112,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -141,23 +131,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "snowflake-cortex", - baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", - token: "options-token", - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + token: "options-token", }, - {}, - ) + }) expect(result.sdk).toBeDefined() }), ), @@ -167,19 +154,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.options.includeUsage).toBe(true) }), ), diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index b780124a6b0..439a26e66a3 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* TogetherAIPlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -34,19 +36,16 @@ describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/togetherai", - options: { name: "togetherai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -54,34 +53,27 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "file:///tmp/@ai-sdk/togetherai-provider.js", - options: { name: "togetherai" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/@ai-sdk/togetherai-provider.js", + options: { name: "togetherai" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/togetherai", - options: { name: "togetherai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -89,20 +81,17 @@ describe("TogetherAIPlugin", () => { it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/togetherai", - options: { name: "custom-togetherai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "custom-togetherai" }, + }) expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") }), @@ -111,28 +100,25 @@ describe("TogetherAIPlugin", () => { it.effect("defaults language selection to sdk.languageModel with the model API ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("togetherai"), - ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - ), - api: { - id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - type: "aisdk", - package: "test-provider", - }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("togetherai"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + type: "aisdk", + package: "test-provider", + }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(result.language).toBeUndefined() expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index 639543af5bc..a8a7c1f0bd1 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: VenicePlugin.id, effect: VenicePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* VenicePlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -34,19 +36,16 @@ describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "venice-ai-sdk-provider", - options: { name: "venice" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "venice" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -54,19 +53,16 @@ describe("VenicePlugin", () => { it.effect("uses the model provider ID as the bundled Venice SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "venice-ai-sdk-provider", - options: { name: "custom-venice", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "custom-venice", apiKey: "test" }, + }) expect(result.sdk).toBeDefined() expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") }), @@ -75,31 +71,24 @@ describe("VenicePlugin", () => { it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const similar = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "file:///tmp/venice-ai-sdk-provider.js", - options: { name: "venice" }, - }, - {}, - ) - const other = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "venice" }, - }, - {}, - ) + const similar = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/venice-ai-sdk-provider.js", + options: { name: "venice" }, + }) + const other = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "venice" }, + }) expect(similar.sdk).toBeUndefined() expect(other.sdk).toBeUndefined() }), @@ -108,20 +97,17 @@ describe("VenicePlugin", () => { it.effect("leaves Venice language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index 5abc737dd02..3611172e9ca 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* VercelPlugin.effect(host) }) describe("VercelPlugin", () => { @@ -55,19 +57,16 @@ describe("VercelPlugin", () => { it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const event = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), - api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, - }), - package: "@ai-sdk/vercel", - options: { name: "custom-vercel" }, - }, - {}, - ) + const event = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, + }), + package: "@ai-sdk/vercel", + options: { name: "custom-vercel" }, + }) expect(event.sdk).toBeDefined() expect(event.sdk.languageModel("v0-1.0-md").provider).toBe("vercel.chat") }), diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index a978381dea5..b5f79e4c092 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* XAIPlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -34,33 +36,26 @@ describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - package: "@ai-sdk/openai-compatible", - options: {}, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/openai-compatible", + options: {}, + }) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - package: "@ai-sdk/xai", - options: {}, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }) expect(ignored.sdk).toBeUndefined() expect(typeof result.sdk?.responses).toBe("function") @@ -70,20 +65,17 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - package: "@ai-sdk/xai", - options: {}, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }) expect(result.sdk.responses("grok-4").provider).toBe("xai.responses") }), @@ -92,21 +84,18 @@ describe("XAIPlugin", () => { it.effect("uses responses with the model api.id for xAI language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:grok-4"]) expect(result.language).toBeDefined() @@ -116,21 +105,18 @@ describe("XAIPlugin", () => { it.effect("ignores non-xAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 3313cd048ae..b9d34a1f5bb 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -13,8 +13,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + yield* ZenmuxPlugin.effect(host) }) function required(value: T | undefined): T { diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 9fefec820fe..19ce82d81a6 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -20,7 +20,7 @@ describe("SkillPlugin.Plugin", () => { it.effect("registers the built-in customize-opencode skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect(host({ skill })) + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) expect(yield* skill.list()).toContainEqual( expect.objectContaining({ diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 5e1aba1923c..621f9a6766f 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -1,7 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AbsolutePath } from "@opencode-ai/core/schema" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Reference } from "@opencode-ai/core/reference" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { SystemContext } from "@opencode-ai/core/system-context/index" @@ -36,7 +35,6 @@ describe("ReferenceGuidance", () => { ]), }), ), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) @@ -48,7 +46,6 @@ describe("ReferenceGuidance", () => { }).pipe( Effect.provide(ReferenceGuidance.layer), Effect.provide(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) @@ -71,7 +68,6 @@ describe("ReferenceGuidance", () => { ]), }), ), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) }) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index fce6ea1087e..9b35d9083ce 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -2,7 +2,6 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SystemContext } from "@opencode-ai/core/system-context" @@ -28,11 +27,8 @@ const denied = new SkillV2.Info({ content: "Denied guidance", }) -const layer = (list: () => SkillV2.Info[], wait: () => void = () => {}) => - SkillGuidance.layer.pipe( - Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })), - Layer.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.sync(wait) })), - ) +const layer = (list: () => SkillV2.Info[]) => + SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) }))) describe("SkillGuidance", () => { it.effect("renders described agent skills and reconciles the complete available list", () => { @@ -41,14 +37,12 @@ describe("SkillGuidance", () => { permissions: [{ action: "skill", resource: "denied", effect: "deny" }], }) let skills = [hidden, denied, effect] - let waited = 0 return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service const initialized = yield* guidance .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap(SystemContext.initialize)) - expect(waited).toBe(1) expect(initialized.baseline).toBe( [ "Skills provide specialized instructions and workflows for specific tasks.", @@ -71,14 +65,7 @@ describe("SkillGuidance", () => { _tag: "Updated", text: expect.stringContaining("No skills are currently available."), }) - }).pipe( - Effect.provide( - layer( - () => skills, - () => waited++, - ), - ), - ) + }).pipe(Effect.provide(layer(() => skills))) }) it.effect("omits guidance when the selected agent denies all skills", () => { diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index 70d522c3fc3..505cd724e7e 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -35,7 +35,7 @@ describe("State", () => { }), ) - it.effect("runs effectful transforms during every rebuild", () => + it.effect("runs effectful transforms during every reload", () => Effect.gen(function* () { let value = "first" const state = State.create({ @@ -51,7 +51,7 @@ describe("State", () => { expect(state.get().values).toEqual(["first"]) value = "second" - yield* state.rebuild() + yield* state.reload() expect(state.get().values).toEqual(["second"]) }), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 3a54be6c0b7..2848fc37aa1 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -5,7 +5,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { PermissionV2 } from "@opencode-ai/core/permission" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SkillV2 } from "@opencode-ai/core/skill" @@ -42,17 +41,6 @@ describe("SkillTool", () => { let current = [info] const assertions: PermissionV2.AssertInput[] = [] let deny = false - let bootWaited = false - const boot = Layer.succeed( - PluginBoot.Service, - PluginBoot.Service.of({ - add: () => Effect.void, - wait: () => - Effect.sync(() => { - bootWaited = true - }), - }), - ) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ @@ -71,7 +59,7 @@ describe("SkillTool", () => { SkillV2.Service, SkillV2.Service.of({ transform: (_transform) => Effect.die("unused"), - rebuild: () => Effect.die("unused"), + reload: () => Effect.die("unused"), sources: () => Effect.die("unused"), list: () => Effect.succeed(current), }), @@ -81,14 +69,12 @@ describe("SkillTool", () => { Layer.provide(registry), Layer.provide(permission), Layer.provide(FSUtil.defaultLayer), - Layer.provide(boot), Layer.provide(skills), ) - const layer = Layer.mergeAll(permission, skills, registry, boot, tool) + const layer = Layer.mergeAll(permission, skills, registry, tool) return yield* Effect.gen(function* () { const registry = yield* ToolRegistry.Service - expect(bootWaited).toBe(true) expect((yield* toolDefinitions(registry))[0]).toMatchObject({ name: "skill", description: SkillTool.description, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b1430314fff..8e6480538ed 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -28,7 +28,6 @@ import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" @@ -100,7 +99,6 @@ export const layer = Layer.effect( const cfg = yield* config.get() const skillDirs = yield* skill.dirs() const referenceDirs = yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) const whitelistedDirs = [ diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 02ad579acb0..87a67b4bc06 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -13,7 +12,6 @@ export const V2Command = effectCmd({ instance: false, handler: () => Effect.gen(function* () { - yield* PluginBoot.Service.use((service) => service.wait()) const catalog = yield* Catalog.Service const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id)) const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id)) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 74401779d35..49b79018579 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -19,7 +19,6 @@ import { Skill } from "@/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Reference } from "@opencode-ai/core/reference" export function provider(model: Provider.Model) { @@ -55,7 +54,6 @@ export const layer = Layer.effect( environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { const ctx = yield* InstanceState.context const references = yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) return [ diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 16a0a6b5e8c..ef213475004 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -12,7 +12,8 @@ ".": "./src/index.ts", "./tool": "./src/tool.ts", "./tui": "./src/tui.ts", - "./v2/effect": "./src/v2/effect/index.ts" + "./v2/effect": "./src/v2/effect/index.ts", + "./v2/promise": "./src/v2/promise/index.ts" }, "files": [ "dist" diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 4fbf469d879..3da1d7b566d 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -1,585 +1,111 @@ -# OpenCode V2 Plugin API +# OpenCode V2 Effect Plugin API -> Design proposal. The API shown here is the intended V2 model and is not fully implemented yet. +The Effect plugin API grants plugins two in-process capabilities: -This document explains how OpenCode V2 plugins contribute agents, commands, skills, integrations, providers, and models without importing `@opencode-ai/core`. +- `hook` installs behavior at an OpenCode extension point. +- `reload` reruns every transform hook for a stateful domain. -The design has four goals: +The public server client will be exposed separately. It is intentionally not part of `PluginContext` yet. -- Internal and external plugins use the same API. -- Plugin values use generated `@opencode-ai/sdk` types. -- Core may keep richer internal representations such as branded IDs and decoded Effect schemas. -- Plugins can react to changing data without reloading an entire Location. - -## Mental Model - -A plugin has two parts: - -1. A setup effect that loads data, starts scoped subscriptions, and returns hooks. -2. Singular transform hooks that describe the plugin's current contribution to a domain. +## Defining A Plugin ```ts -export default defineEffectPlugin({ +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export const Plugin = define({ id: "example", - effect: (ctx) => - Effect.gen(function* () { - return { - "agent.transform": (agent) => { - // Describe this plugin's agent contribution. - }, - } - }), + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }), }) ``` -A transform is not a one-time mutation. It is a replayable declaration. +Plugin setup registers hooks imperatively. It does not return a hook object. -OpenCode may run it when: +Configuration supplied for the plugin is available as `ctx.options`. -- The plugin is added. -- The plugin is removed or replaced. -- Another plugin affecting the same domain changes. -- The plugin explicitly invalidates the domain. +Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`. -Transforms must therefore be synchronous, deterministic, and safe to rerun. +## Transform Hooks -## Why Hooks Are Returned - -Each transform is a singular property of the plugin definition: +Transform hooks contribute to stateful domains: ```ts -return { - "catalog.transform": applyCatalog, -} -``` - -This makes it structurally clear that one plugin has at most one transform per domain. There is no ambiguous behavior from calling `transform()` multiple times during setup. - -Transforms from different plugins compose in plugin order. - -```text -models.dev catalog transform -→ config catalog transform -→ provider catalog transforms -→ user catalog transforms -→ core catalog finalizer -``` - -## Your First Plugin - -This plugin adds a reviewer agent. - -```ts -import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" -import { Effect } from "effect" - -export default defineEffectPlugin({ - id: "reviewer", - effect: () => - Effect.succeed({ - "agent.transform": (agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code for correctness and regressions" - item.system = "Review the requested code. Prioritize bugs and behavioral regressions." - item.mode = "subagent" - item.hidden = false - }) - }, - }), -}) -``` - -The editor supplies a complete default agent when `reviewer` does not exist. The callback modifies that value using the generated SDK agent shape. - -When the plugin unloads, OpenCode rebuilds the agent registry without this transform. The reviewer disappears automatically. - -## Transform Editors - -Editors support ordered reads and writes while a domain is being rebuilt. - -```ts -"agent.transform": (agent) => { - const existing = agent.get("reviewer") - - agent.update("reviewer", (item) => { - item.description ??= existing?.description ?? "Reviews code" +yield * + ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) }) -} ``` -An editor is valid only during the transform call. Do not retain it in plugin state. +OpenCode rebuilds the domain when a transform is registered or disposed. A rebuild starts from fresh domain state and runs every active transform in registration order. -Later plugins see mutations made by earlier plugins in the same rebuild. - -## Adding A Provider And Model - -This plugin contributes one provider and one model. +Available transform hooks are namespaced by domain: ```ts -import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" -import { Effect } from "effect" - -export default defineEffectPlugin({ - id: "acme", - effect: () => - Effect.succeed({ - "catalog.transform": (catalog) => { - catalog.provider.update("acme", (provider) => { - provider.name = "Acme AI" - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.acme.example/v1", - } - }) - - catalog.model.update("acme", "acme-chat", (model) => { - model.name = "Acme Chat" - model.family = "acme" - model.api = { - id: "acme-chat", - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.acme.example/v1", - } - model.capabilities = { - tools: true, - input: ["text"], - output: ["text"], - } - model.time.released = Date.now() - model.status = "active" - model.enabled = true - model.limit = { - context: 128_000, - output: 16_384, - } - }) - }, - }), -}) +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform ``` -The provider and model values use generated SDK types. Core may encode and decode richer internal schema values at the plugin boundary. - -## Dynamic Data And Invalidation - -Some plugins depend on data that changes after setup. Examples include: - -- models.dev refreshes -- config file watchers -- skill directory watchers -- authentication state changes - -The plugin keeps the current data in its own scoped state. When that data changes, it invalidates each affected domain. - -```ts -let data = yield * loadData() - -return { - "catalog.transform": (catalog) => { - applyCatalog(data, catalog) - }, -} -``` - -After changing `data`: - -```ts -data = yield * loadData() -yield * ctx.catalog.invalidate() -``` - -Invalidation does not mutate the current catalog in place. It requests a rebuild: - -```text -create fresh catalog state -→ replay every catalog transform in plugin order -→ run the core catalog finalizer -→ commit the new catalog -→ publish catalog.updated -``` - -Repeated invalidations are serialized and may be coalesced. - -## Models.dev Example - -Models.dev is the main example of a dynamic plugin. It projects one changing source into the integration and catalog domains. - -```ts -import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" -import { Effect, Stream } from "effect" - -export default defineEffectPlugin({ - id: "models-dev", - effect: (ctx) => - Effect.gen(function* () { - const modelsDev = yield* ModelsDev.Service - const events = yield* EventV2.Service - let data = yield* modelsDev.get() - - yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach( - Effect.fn(function* () { - data = yield* modelsDev.get() - yield* ctx.integration.invalidate() - yield* ctx.catalog.invalidate() - }), - ), - Effect.forkScoped({ startImmediately: true }), - ) - - return { - "integration.transform": (integration) => { - for (const provider of Object.values(data)) { - if (provider.env.length === 0) continue - - integration.update(provider.id, (item) => { - item.name = provider.name - }) - - integration.method.update({ - integrationID: provider.id, - method: { type: "key" }, - }) - - integration.method.update({ - integrationID: provider.id, - method: { - type: "env", - names: [...provider.env], - }, - }) - } - }, - - "catalog.transform": (catalog) => { - for (const provider of Object.values(data)) { - applyProvider(provider, catalog) - } - }, - } - }), -}) -``` - -`ModelsDev.Service` and `ModelsDev.Event` are privileged internal dependencies in this example. The integration and catalog contributions still use the same hooks available to external plugins. - -This design intentionally does not require a special multi-domain transform. The two domains rebuild independently. If strict cross-domain atomic publication becomes a requirement, it should be designed separately rather than making every transform combinatorial. - -## Config File Watching - -A config plugin can project one parsed config snapshot into several independent domains. - -```ts -export default defineEffectPlugin({ - id: "config", - effect: (ctx) => - Effect.gen(function* () { - let config = yield* loadConfig() - - yield* watchConfig.pipe( - Stream.runForEach( - Effect.fn(function* () { - config = yield* loadConfig() - yield* ctx.agent.invalidate() - yield* ctx.command.invalidate() - yield* ctx.catalog.invalidate() - yield* ctx.integration.invalidate() - yield* ctx.reference.invalidate() - yield* ctx.skill.invalidate() - }), - ), - Effect.forkScoped, - ) - - return { - "agent.transform": (agent) => applyAgentConfig(config, agent), - "command.transform": (command) => applyCommandConfig(config, command), - "catalog.transform": (catalog) => applyProviderConfig(config, catalog), - "integration.transform": (integration) => applyIntegrationConfig(config, integration), - "reference.transform": (reference) => applyReferenceConfig(config, reference), - "skill.transform": (skill) => applySkillConfig(config, skill), - } - }), -}) -``` - -The watcher performs I/O. The transforms only project the latest in-memory snapshot. - -## Skill Directory Watching - -A skill plugin follows the same pattern. - -```ts -export default defineEffectPlugin({ - id: "workspace-skills", - effect: (ctx) => - Effect.gen(function* () { - let sources = yield* discoverSkills() - - yield* watchSkillDirectories.pipe( - Stream.runForEach( - Effect.fn(function* () { - sources = yield* discoverSkills() - yield* ctx.skill.invalidate() - }), - ), - Effect.forkScoped, - ) - - return { - "skill.transform": (skill) => { - for (const source of sources) skill.source(source) - }, - } - }), -}) -``` - -Rebuilding the source registry may not be enough if discovered skill contents are cached separately. Domain invalidation must include all materialized state owned by that domain. - ## Runtime Hooks -Transform hooks build registry state. Runtime hooks intercept live operations. +Runtime hooks intercept live operations rather than rebuilding domain state: ```ts -return { - "catalog.transform": (catalog) => { - // Synchronous and replayable. - }, - - "aisdk.sdk": Effect.fn(function* (event) { - // Runs when OpenCode needs an AI SDK provider. - }), - - "aisdk.language": Effect.fn(function* (event) { - // Runs when OpenCode selects a language model implementation. - }), -} -``` - -Runtime hooks may perform Effects appropriate to the operation. Transform hooks must remain replay-safe. - -## Integration Authentication - -Executable registrations may be installed during an integration transform. - -```ts -return { - "integration.transform": (integration) => { - integration.update("openai", (item) => { - item.name = "OpenAI" - }) - - integration.method.update({ - integrationID: "openai", - method: { - id: "chatgpt-browser", - type: "oauth", - label: "ChatGPT Pro/Plus (browser)", - }, - authorize: browserAuthorize, - refresh: refreshCredential, - }) - }, -} -``` - -Replay installs callback values. It must not start OAuth, open a server, or refresh credentials. Those effects run later when core invokes the stored implementation. - -## Reading Other Domains - -A transform may need information from another committed domain. - -```ts -"agent.transform": (agent) => { - if (!anthropicAvailable) return - - agent.update("anthropic-reviewer", (item) => { - item.model = { - providerID: "anthropic", - id: "claude-sonnet", - } - }) -} -``` - -Load or subscribe to the dependency during setup, keep a local snapshot, and invalidate the dependent domain when the snapshot changes. - -```ts -let anthropicAvailable = yield * readAnthropicAvailability() +yield * + ctx.aisdk.sdk( + Effect.fn(function* (event) { + if (event.package !== "@ai-sdk/xai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) + event.sdk = mod.createXai(event.options) + }), + ) yield * - catalogChanges.pipe( - Stream.runForEach( - Effect.fn(function* () { - anthropicAvailable = yield* readAnthropicAvailability() - yield* ctx.agent.invalidate() - }), - ), - Effect.forkScoped, - ) -``` - -This keeps transform callbacks synchronous and avoids hidden dependency tracking. - -## Plugin Order - -OpenCode's default distribution uses an opinionated order. - -```text -1. Built-in agents, commands, and skills -2. Base data sources such as models.dev -3. Configuration projections -4. Provider-specific normalization and authentication -5. External user plugins -6. Core domain finalization -``` - -For the catalog: - -```text -models.dev -→ config provider overrides -→ built-in provider normalization -→ user catalog transforms -→ policy and validation -→ commit -→ catalog.updated -``` - -Ordering is observable behavior. Later transforms see and may override earlier transforms. - -## Core Finalization - -Plugin transforms and core finalization are different concepts. - -Transforms describe configurable plugin contributions. Core finalization enforces domain invariants. - -Catalog finalization may: - -- Validate the materialized catalog. -- Apply provider-use policy. -- Build indexes. -- Commit the new snapshot. -- Publish `catalog.updated` after the new snapshot is visible. - -Reference finalization may materialize Git-backed references. Integration finalization may update connection projections and publish events. - -Core finalizers always run after plugin transforms for that domain. - -## Add, Remove, And Replace - -When a plugin is added, OpenCode invalidates every domain for which it returned a transform. - -When a plugin is removed, OpenCode removes its hooks and invalidates those domains. Rebuilding from base state automatically removes the plugin's prior mutations. - -When a plugin is replaced, OpenCode swaps its hooks, preserves the intended plugin order, and invalidates the affected domains. - -No plugin-specific undo callback is required. - -## Effect API - -The Effect API exposes Effect-native setup, runtime hooks, scopes, interruption, and typed failures. - -```ts -export type EffectPlugin = (ctx: EffectPluginContext) => Effect.Effect -``` - -The setup scope owns: - -- Event subscriptions -- Watchers -- Background fibers -- Plugin hooks - -Closing the scope unloads the plugin and invalidates its transformed domains. - -## Promise API - -The Promise API uses the same SDK values, hook names, editors, and lifecycle semantics. - -```ts -export default definePlugin({ - id: "reviewer", - plugin: async () => ({ - "agent.transform": (agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code" - item.mode = "subagent" - item.hidden = false - }) - }, - }), -}) -``` - -Promise plugins receive Promise-returning host capabilities: - -```ts -await ctx.catalog.invalidate() -``` - -Core implements the Promise API by running the canonical Effect capabilities. It manages the plugin scope automatically. - -## Rules For Transform Hooks - -Transform hooks must: - -- Be synchronous. -- Be deterministic for their captured snapshot. -- Avoid network, filesystem, process, and database I/O. -- Avoid publishing events. -- Avoid invalidating a domain while that domain is rebuilding. -- Avoid retaining the editor after returning. - -Transform hooks may: - -- Read the editor's current materialized state. -- Add, update, and remove domain entries. -- Install executable callback values for later use. -- Read immutable or plugin-owned captured data. - -## Runtime Requirements - -The plugin runtime must provide these guarantees: - -- Hooks replay in deterministic plugin order. -- Only one rebuild per domain runs at a time. -- Repeated invalidations may be coalesced. -- Rebuilds use fresh temporary state. -- Failed rebuilds leave the previous committed state intact. -- Core finalization runs after all plugin transforms. -- Update events publish only after the new state is visible. -- Plugin add, remove, and replacement invalidate affected domains automatically. -- A transform cannot invalidate the domain currently running it. - -## Summary - -Use setup for effects and transforms for declarations. - -```ts -effect: (ctx) => - Effect.gen(function* () { - let data = yield* loadData() - - yield* watchData.pipe( - Stream.runForEach( - Effect.fn(function* () { - data = yield* loadData() - yield* ctx.catalog.invalidate() - }), - ), - Effect.forkScoped, - ) - - return { - "catalog.transform": (catalog) => { - applyCatalog(data, catalog) - }, - } + ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) }) ``` -The plugin owns changing source data. The runtime owns hook ordering, replay, invalidation, cleanup, and commit. Core services own their state and finalization. +Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. + +## Reloading A Domain + +When data captured by a transform changes, reload the affected domain: + +```ts +let data = yield * loadCatalog() + +yield * + ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) + }) + +data = yield * loadCatalog() +yield * ctx.catalog.reload() +``` + +Reload belongs to the domain, not an individual registration. `ctx.catalog.reload()` reruns every active catalog transform and publishes the rebuilt catalog. + +Available reload operations are: + +```ts +ctx.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() +``` diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index 11a8c6c20ed..9ded7b831dc 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,6 +1,5 @@ import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export interface AgentDraft { list(): readonly AgentV2Info[] @@ -10,8 +9,6 @@ export interface AgentDraft { remove(id: string): void } -export interface Agent extends Transformable { - get(id: string): Effect.Effect - default(): Effect.Effect - list(): Effect.Effect -} +export type AgentHooks = Hooks<{ + transform: AgentDraft +}> diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts index 579de82496e..ddc292e3842 100644 --- a/packages/plugin/src/v2/effect/aisdk.ts +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -1,21 +1,18 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Hookable } from "./registration.js" +import type { Hooks } from "./registration.js" -export interface AISDKHooks { - readonly sdk: (event: { +export type AISDKHooks = Hooks<{ + sdk: { readonly model: ModelV2Info readonly package: string readonly options: Record sdk?: any - }) => Effect.Effect | void - readonly language: (event: { + } + language: { readonly model: ModelV2Info readonly sdk: any readonly options: Record language?: LanguageModelV3 - }) => Effect.Effect | void -} - -export interface AISDK extends Hookable {} + } +}> diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 1d44717aefd..704ef16bf63 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,6 +1,5 @@ import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export interface CatalogProviderRecord { readonly provider: ProviderV2Info @@ -25,17 +24,6 @@ export interface CatalogDraft { } } -export interface Catalog extends Transformable { - readonly provider: { - get(id: string): Effect.Effect - list(): Effect.Effect - available(): Effect.Effect - } - readonly model: { - get(providerID: string, modelID: string): Effect.Effect - list(): Effect.Effect - available(): Effect.Effect - default(): Effect.Effect - small(providerID: string): Effect.Effect - } -} +export type CatalogHooks = Hooks<{ + transform: CatalogDraft +}> diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts index fcb90d19685..0afd4bafe04 100644 --- a/packages/plugin/src/v2/effect/command.ts +++ b/packages/plugin/src/v2/effect/command.ts @@ -1,6 +1,5 @@ import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export interface CommandDraft { list(): readonly CommandV2Info[] @@ -9,7 +8,6 @@ export interface CommandDraft { remove(name: string): void } -export interface Command extends Transformable { - get(name: string): Effect.Effect - list(): Effect.Effect -} +export type CommandHooks = Hooks<{ + transform: CommandDraft +}> diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts new file mode 100644 index 00000000000..76ba7967401 --- /dev/null +++ b/packages/plugin/src/v2/effect/context.ts @@ -0,0 +1,22 @@ +import type { PluginOptions } from "../options.js" +import type { AgentHooks } from "./agent.js" +import type { AISDKHooks } from "./aisdk.js" +import type { CatalogHooks } from "./catalog.js" +import type { CommandHooks } from "./command.js" +import type { IntegrationHooks } from "./integration.js" +import type { PluginHooks } from "./plugin.js" +import type { ReferenceHooks } from "./reference.js" +import type { SkillHooks } from "./skill.js" +import type { Reload } from "./registration.js" + +export interface PluginContext { + readonly options: PluginOptions + readonly agent: AgentHooks & Reload + readonly aisdk: AISDKHooks + readonly catalog: CatalogHooks & Reload + readonly command: CommandHooks & Reload + readonly integration: IntegrationHooks & Reload + readonly plugin: PluginHooks & Reload + readonly reference: ReferenceHooks & Reload + readonly skill: SkillHooks & Reload +} diff --git a/packages/plugin/src/v2/effect/host.ts b/packages/plugin/src/v2/effect/host.ts deleted file mode 100644 index 707f744b35f..00000000000 --- a/packages/plugin/src/v2/effect/host.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Agent } from "./agent.js" -import type { AISDK } from "./aisdk.js" -import type { Catalog } from "./catalog.js" -import type { Command } from "./command.js" -import type { Event } from "./event.js" -import type { FileSystem } from "./filesystem.js" -import type { Integration } from "./integration.js" -import type { Location } from "./location.js" -import type { Npm } from "./npm.js" -import type { Path } from "./path.js" -import type { Reference } from "./reference.js" -import type { Skill } from "./skill.js" - -export interface PluginHost { - readonly agent: Agent - readonly aisdk: AISDK - readonly catalog: Catalog - readonly command: Command - readonly event: Event - readonly filesystem: FileSystem - readonly integration: Integration - readonly location: Location - readonly npm: Npm - readonly path: Path - readonly reference: Reference - readonly skill: Skill -} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 46c4574515f..928649c0508 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,17 +1,3 @@ -export type { PluginHost } from "./host.js" +export type { PluginContext } from "./context.js" export { define } from "./plugin.js" -export type { Plugin } from "./plugin.js" -export type { Registration } from "./registration.js" -export type { Agent, AgentDraft } from "./agent.js" -export type { AISDK, AISDKHooks } from "./aisdk.js" -export type { Catalog, CatalogDraft, CatalogProviderRecord } from "./catalog.js" -export type { Command, CommandDraft } from "./command.js" -export type { Event, EventMap } from "./event.js" -export type { FileSystem } from "./filesystem.js" -export type { Integration, IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "./integration.js" -export type { Location } from "./location.js" -export type { Npm } from "./npm.js" -export type { Path } from "./path.js" -export type { Reference, ReferenceDraft } from "./reference.js" -export type { Hookable, Transform, Transformable } from "./registration.js" -export type { Skill, SkillDraft, SkillSource } from "./skill.js" +export type { Plugin, PluginDraft } from "./plugin.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 2acb08b5793..5caec6888e4 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -4,8 +4,7 @@ import type { IntegrationKeyMethod, IntegrationOAuthMethod, } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod export type IntegrationMethodRegistration = @@ -30,7 +29,6 @@ export interface IntegrationDraft { } } -export interface Integration extends Transformable { - get(id: string): Effect.Effect - list(): Effect.Effect -} +export type IntegrationHooks = Hooks<{ + transform: IntegrationDraft +}> diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 09c919ad6b0..75008d92e0d 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,11 +1,28 @@ import type { Effect, Scope } from "effect" -import type { PluginHost } from "./host.js" +import type { PluginContext } from "./context.js" +import type { PluginOptions } from "../options.js" +import type { Hooks } from "./registration.js" -export interface Plugin { +export interface Plugin { readonly id: string - readonly effect: (host: PluginHost) => Effect.Effect + readonly effect: (context: PluginContext) => Effect.Effect } -export function define(plugin: Plugin) { +export function define(plugin: Plugin) { return plugin } + +export interface PluginRef { + readonly package: string + readonly options?: PluginOptions +} + +export interface PluginDraft { + list(): readonly Plugin[] + add(plugin: Plugin): void + remove(id: string): void +} + +export type PluginHooks = Hooks<{ + transform: PluginDraft +}> diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts index 389674cff7e..c1e2630476e 100644 --- a/packages/plugin/src/v2/effect/reference.ts +++ b/packages/plugin/src/v2/effect/reference.ts @@ -1,6 +1,5 @@ -import type { ReferenceGitSource, ReferenceInfo, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" +import type { Hooks } from "./registration.js" export interface ReferenceDraft { add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void @@ -8,6 +7,6 @@ export interface ReferenceDraft { list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] } -export interface Reference extends Transformable { - list(): Effect.Effect -} +export type ReferenceHooks = Hooks<{ + transform: ReferenceDraft +}> diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts index 05aa0c4b606..dfe56263976 100644 --- a/packages/plugin/src/v2/effect/registration.ts +++ b/packages/plugin/src/v2/effect/registration.ts @@ -1,16 +1,15 @@ import type { Effect, Scope } from "effect" -export type Transform = (draft: Draft) => Effect.Effect | void - export interface Registration { readonly dispose: Effect.Effect } -export interface Transformable { - transform(callback: Transform): Effect.Effect - rebuild(): Effect.Effect +export interface Reload { + readonly reload: () => Effect.Effect } -export interface Hookable { - hook(name: Name, callback: Hooks[Name]): Effect.Effect +export type Hooks = { + readonly [Name in keyof Spec]: ( + callback: (input: Spec[Name]) => Effect.Effect | void, + ) => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts index d25a71f0d3e..88dd3867d18 100644 --- a/packages/plugin/src/v2/effect/skill.ts +++ b/packages/plugin/src/v2/effect/skill.ts @@ -1,6 +1,5 @@ import type { SkillV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export type SkillSource = | { readonly type: "directory"; readonly path: string } @@ -12,7 +11,6 @@ export interface SkillDraft { list(): readonly SkillSource[] } -export interface Skill extends Transformable { - sources(): Effect.Effect - list(): Effect.Effect -} +export type SkillHooks = Hooks<{ + transform: SkillDraft +}> diff --git a/packages/plugin/src/v2/options.ts b/packages/plugin/src/v2/options.ts new file mode 100644 index 00000000000..2b210f943b2 --- /dev/null +++ b/packages/plugin/src/v2/options.ts @@ -0,0 +1 @@ +export type PluginOptions = Readonly> diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md new file mode 100644 index 00000000000..e91b93fbdfc --- /dev/null +++ b/packages/plugin/src/v2/promise/README.md @@ -0,0 +1,103 @@ +# OpenCode V2 Promise Plugin API + +The Promise plugin API is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities: + +- `hook` installs behavior at an OpenCode extension point. +- `reload` reruns every transform hook for a stateful domain. + +The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects. + +## Defining A Plugin + +```ts +import { define } from "@opencode-ai/plugin/v2/promise" + +export const Plugin = define({ + id: "example", + setup: async (ctx) => { + await ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }, +}) +``` + +Plugin setup registers hooks imperatively. It does not return a hook object. + +Configuration supplied for the plugin is available as `ctx.options`. + +A registration may be removed early through `dispose`: + +```ts +const registration = await ctx.catalog.transform(applyCatalog) +await registration.dispose() +``` + +## Transform Hooks + +Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work: + +```ts +await ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) +}) +``` + +Available transform hooks are namespaced by domain: + +```ts +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform +``` + +## Runtime Hooks + +Runtime hooks intercept live operations: + +```ts +await ctx.aisdk.sdk(async (event) => { + if (event.package !== "@ai-sdk/xai") return + const mod = await import("@ai-sdk/xai") + event.sdk = mod.createXai(event.options) +}) + +await ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) +}) +``` + +## Reloading A Domain + +When data captured by a transform changes, reload the affected domain: + +```ts +let data = await loadCatalog() + +await ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) +}) + +data = await loadCatalog() +await ctx.catalog.reload() +``` + +Available reload operations are: + +```ts +ctx.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() +``` diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts new file mode 100644 index 00000000000..bec589146ae --- /dev/null +++ b/packages/plugin/src/v2/promise/agent.ts @@ -0,0 +1,8 @@ +import type { AgentDraft } from "../effect/agent.js" +import type { Hooks } from "./registration.js" + +export type { AgentDraft } + +export type AgentHooks = Hooks<{ + transform: AgentDraft +}> diff --git a/packages/plugin/src/v2/promise/aisdk.ts b/packages/plugin/src/v2/promise/aisdk.ts new file mode 100644 index 00000000000..ddc292e3842 --- /dev/null +++ b/packages/plugin/src/v2/promise/aisdk.ts @@ -0,0 +1,18 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export type AISDKHooks = Hooks<{ + sdk: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any + } + language: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 + } +}> diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts new file mode 100644 index 00000000000..70842e94b71 --- /dev/null +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -0,0 +1,8 @@ +import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" +import type { Hooks } from "./registration.js" + +export type { CatalogDraft, CatalogProviderRecord } + +export type CatalogHooks = Hooks<{ + transform: CatalogDraft +}> diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts new file mode 100644 index 00000000000..cdc5f8268a1 --- /dev/null +++ b/packages/plugin/src/v2/promise/command.ts @@ -0,0 +1,8 @@ +import type { CommandDraft } from "../effect/command.js" +import type { Hooks } from "./registration.js" + +export type { CommandDraft } + +export type CommandHooks = Hooks<{ + transform: CommandDraft +}> diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts new file mode 100644 index 00000000000..76ba7967401 --- /dev/null +++ b/packages/plugin/src/v2/promise/context.ts @@ -0,0 +1,22 @@ +import type { PluginOptions } from "../options.js" +import type { AgentHooks } from "./agent.js" +import type { AISDKHooks } from "./aisdk.js" +import type { CatalogHooks } from "./catalog.js" +import type { CommandHooks } from "./command.js" +import type { IntegrationHooks } from "./integration.js" +import type { PluginHooks } from "./plugin.js" +import type { ReferenceHooks } from "./reference.js" +import type { SkillHooks } from "./skill.js" +import type { Reload } from "./registration.js" + +export interface PluginContext { + readonly options: PluginOptions + readonly agent: AgentHooks & Reload + readonly aisdk: AISDKHooks + readonly catalog: CatalogHooks & Reload + readonly command: CommandHooks & Reload + readonly integration: IntegrationHooks & Reload + readonly plugin: PluginHooks & Reload + readonly reference: ReferenceHooks & Reload + readonly skill: SkillHooks & Reload +} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts new file mode 100644 index 00000000000..41254707f3d --- /dev/null +++ b/packages/plugin/src/v2/promise/index.ts @@ -0,0 +1,17 @@ +export type { PluginContext } from "./context.js" +export type { PluginOptions } from "../options.js" +export { define } from "./plugin.js" +export type { Plugin, PluginDraft, PluginHooks, PluginRef } from "./plugin.js" +export type { Registration, Reload } from "./registration.js" +export type { AgentDraft, AgentHooks } from "./agent.js" +export type { AISDKHooks } from "./aisdk.js" +export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" +export type { CommandDraft, CommandHooks } from "./command.js" +export type { + IntegrationDraft, + IntegrationHooks, + IntegrationMethod, + IntegrationMethodRegistration, +} from "./integration.js" +export type { ReferenceDraft, ReferenceHooks } from "./reference.js" +export type { SkillDraft, SkillHooks, SkillSource } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts new file mode 100644 index 00000000000..06afa09d238 --- /dev/null +++ b/packages/plugin/src/v2/promise/integration.ts @@ -0,0 +1,8 @@ +import type { IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "../effect/integration.js" +import type { Hooks } from "./registration.js" + +export type { IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } + +export type IntegrationHooks = Hooks<{ + transform: IntegrationDraft +}> diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts new file mode 100644 index 00000000000..f7ff2ad45c7 --- /dev/null +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -0,0 +1,18 @@ +import type { PluginContext } from "./context.js" +import type { PluginDraft, PluginRef } from "../effect/plugin.js" +import type { Hooks } from "./registration.js" + +export interface Plugin { + readonly id: string + readonly setup: (context: PluginContext) => Promise | void +} + +export function define(plugin: Plugin) { + return plugin +} + +export type { PluginDraft, PluginRef } + +export type PluginHooks = Hooks<{ + transform: PluginDraft +}> diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts new file mode 100644 index 00000000000..f4b7f8b8393 --- /dev/null +++ b/packages/plugin/src/v2/promise/reference.ts @@ -0,0 +1,8 @@ +import type { ReferenceDraft } from "../effect/reference.js" +import type { Hooks } from "./registration.js" + +export type { ReferenceDraft } + +export type ReferenceHooks = Hooks<{ + transform: ReferenceDraft +}> diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts new file mode 100644 index 00000000000..5e0ae7f480a --- /dev/null +++ b/packages/plugin/src/v2/promise/registration.ts @@ -0,0 +1,11 @@ +export interface Registration { + readonly dispose: () => Promise +} + +export interface Reload { + readonly reload: () => Promise +} + +export type Hooks = { + readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise +} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts new file mode 100644 index 00000000000..fbfa1a4a637 --- /dev/null +++ b/packages/plugin/src/v2/promise/skill.ts @@ -0,0 +1,8 @@ +import type { SkillDraft, SkillSource } from "../effect/skill.js" +import type { Hooks } from "./registration.js" + +export type { SkillDraft, SkillSource } + +export type SkillHooks = Hooks<{ + transform: SkillDraft +}> diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index cbde76dbce3..3be2c9d5ea9 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -1,5 +1,4 @@ import { AgentV2 } from "@opencode-ai/core/agent" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" @@ -8,7 +7,6 @@ import { response } from "../groups/location" export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => handlers.handle("agent.list", () => Effect.gen(function* () { - yield* PluginBoot.Service.use((plugin) => plugin.wait()) return yield* response(AgentV2.Service.use((agent) => agent.all())) }), ), diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 71a3f9d8500..542717351b9 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -1,24 +1,15 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ServiceUnavailableError } from "../errors" import { response } from "../groups/location" -const catalogUnavailable = new ServiceUnavailableError({ - message: "Model catalog is unavailable", - service: "catalog", -}) - export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { return handlers.handle( "model.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* response(catalog.model.available()) }), ) diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 8b1c9959e8e..4d84179ed36 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -1,17 +1,11 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" +import { ProviderNotFoundError } from "../errors" import { response } from "../groups/location" -const catalogUnavailable = new ServiceUnavailableError({ - message: "Provider catalog is unavailable", - service: "catalog", -}) - export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => Effect.gen(function* () { return handlers @@ -19,8 +13,6 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* response(catalog.provider.available()) }), ) @@ -28,8 +20,6 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.get", Effect.fn(function* (ctx) { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) const provider = yield* catalog.provider.get(ctx.params.providerID) if (!provider) return yield* new ProviderNotFoundError({ From 49d3f8680285affe15b95c1bdd92faf4c9c68719 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 22 Jun 2026 23:08:26 +0000 Subject: [PATCH 094/112] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 40 ++++++------- packages/sdk/openapi.json | 76 ++++++++++++------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f71b010420d..ef1c0142fb3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -50,10 +50,10 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventFileEdited - | EventPluginAdded + | EventReferenceUpdated | EventPermissionV2Asked | EventPermissionV2Replied - | EventReferenceUpdated + | EventPluginAdded | EventProjectDirectoriesUpdated | EventFileWatcherUpdated | EventPtyCreated @@ -1238,9 +1238,9 @@ export type GlobalEvent = { } | { id: string - type: "plugin.added" + type: "reference.updated" properties: { - id: string + [key: string]: unknown } } | { @@ -1269,9 +1269,9 @@ export type GlobalEvent = { } | { id: string - type: "reference.updated" + type: "plugin.added" properties: { - [key: string]: unknown + id: string } } | { @@ -2787,10 +2787,10 @@ export type V2Event = | V2EventInstallationUpdated | V2EventInstallationUpdateAvailable | V2EventFileEdited - | V2EventPluginAdded + | V2EventReferenceUpdated | V2EventPermissionV2Asked | V2EventPermissionV2Replied - | V2EventReferenceUpdated + | V2EventPluginAdded | V2EventProjectDirectoriesUpdated | V2EventFileWatcherUpdated | V2EventPtyCreated @@ -5164,7 +5164,7 @@ export type V2EventFileEdited = { } } -export type V2EventPluginAdded = { +export type V2EventReferenceUpdated = { id: string metadata?: { [key: string]: unknown @@ -5175,9 +5175,9 @@ export type V2EventPluginAdded = { version: number } location?: LocationRef - type: "plugin.added" + type: "reference.updated" data: { - id: string + [key: string]: unknown } } @@ -5225,7 +5225,7 @@ export type V2EventPermissionV2Replied = { } } -export type V2EventReferenceUpdated = { +export type V2EventPluginAdded = { id: string metadata?: { [key: string]: unknown @@ -5236,9 +5236,9 @@ export type V2EventReferenceUpdated = { version: number } location?: LocationRef - type: "reference.updated" + type: "plugin.added" data: { - [key: string]: unknown + id: string } } @@ -6518,11 +6518,11 @@ export type EventFileEdited = { } } -export type EventPluginAdded = { +export type EventReferenceUpdated = { id: string - type: "plugin.added" + type: "reference.updated" properties: { - id: string + [key: string]: unknown } } @@ -6552,11 +6552,11 @@ export type EventPermissionV2Replied = { } } -export type EventReferenceUpdated = { +export type EventPluginAdded = { id: string - type: "reference.updated" + type: "plugin.added" properties: { - [key: string]: unknown + id: string } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 14c8b94dd0e..932cb8a4954 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14777,7 +14777,7 @@ "$ref": "#/components/schemas/EventFileEdited" }, { - "$ref": "#/components/schemas/EventPluginAdded" + "$ref": "#/components/schemas/EventReferenceUpdated" }, { "$ref": "#/components/schemas/EventPermissionV2Asked" @@ -14786,7 +14786,7 @@ "$ref": "#/components/schemas/EventPermissionV2Replied" }, { - "$ref": "#/components/schemas/EventReferenceUpdated" + "$ref": "#/components/schemas/EventPluginAdded" }, { "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" @@ -18526,17 +18526,11 @@ }, "type": { "type": "string", - "enum": ["plugin.added"] + "enum": ["reference.updated"] }, "properties": { "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false + "properties": {} } }, "required": ["id", "type", "properties"], @@ -18635,11 +18629,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], @@ -23119,7 +23119,7 @@ "$ref": "#/components/schemas/V2EventFileEdited" }, { - "$ref": "#/components/schemas/V2EventPluginAdded" + "$ref": "#/components/schemas/V2EventReferenceUpdated" }, { "$ref": "#/components/schemas/V2EventPermissionV2Asked" @@ -23128,7 +23128,7 @@ "$ref": "#/components/schemas/V2EventPermissionV2Replied" }, { - "$ref": "#/components/schemas/V2EventReferenceUpdated" + "$ref": "#/components/schemas/V2EventPluginAdded" }, { "$ref": "#/components/schemas/V2EventProjectDirectoriesUpdated" @@ -30431,7 +30431,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPluginAdded": { + "V2EventReferenceUpdated": { "type": "object", "properties": { "id": { @@ -30462,17 +30462,11 @@ }, "type": { "type": "string", - "enum": ["plugin.added"] + "enum": ["reference.updated"] }, "data": { "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false + "properties": {} } }, "required": ["id", "type", "data"], @@ -30606,7 +30600,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventReferenceUpdated": { + "V2EventPluginAdded": { "type": "object", "properties": { "id": { @@ -30637,11 +30631,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "data": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "data"], @@ -34370,7 +34370,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventPluginAdded": { + "EventReferenceUpdated": { "type": "object", "properties": { "id": { @@ -34379,17 +34379,11 @@ }, "type": { "type": "string", - "enum": ["plugin.added"] + "enum": ["reference.updated"] }, "properties": { "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false + "properties": {} } }, "required": ["id", "type", "properties"], @@ -34479,7 +34473,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventReferenceUpdated": { + "EventPluginAdded": { "type": "object", "properties": { "id": { @@ -34488,11 +34482,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], From 23fd5907be901c0b6727e7646823d4168d43389f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 19:15:26 -0400 Subject: [PATCH 095/112] fix(opencode): normalize CLI test line endings --- packages/opencode/test/lib/cli-process.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 9c34e336bb2..6b16ab74b54 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -237,8 +237,8 @@ export function withCliFixture( ) return { exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), + stdout: normalizeLines(result.stdout.toString()), + stderr: normalizeLines(result.stderr.toString()), durationMs: Date.now() - start, } }) @@ -299,8 +299,8 @@ export function withCliFixture( interrupt: () => proc.kill("SIGINT"), result: Effect.promise(async () => ({ exitCode: await proc.exited, - stdout: await stdout, - stderr: await stderr, + stdout: normalizeLines(await stdout), + stderr: normalizeLines(await stderr), durationMs: Date.now() - start, })), } satisfies RunHandle @@ -479,6 +479,10 @@ function parseJsonEvents(stdout: string): Array> { .map((line) => JSON.parse(line) as Record) } +function normalizeLines(value: string) { + return value.replaceAll("\r\n", "\n") +} + // Convenience for the common assertion pattern. Dumps stderr/stdout when // the exit code doesn't match — saves debugging time on CI failures. function expectExit(result: RunResult, expected: number, label = "opencode") { From cd97de7391e9589c5586928e185752c2fd7dd9ab Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 19:49:00 -0400 Subject: [PATCH 096/112] refactor(plugin): consolidate internal registration --- packages/core/src/location-layer.ts | 4 +- packages/core/src/plugin/boot.ts | 101 ------------------------ packages/core/src/plugin/internal.ts | 111 +++++++++++++++++++++++---- packages/core/src/plugin/promise.ts | 2 +- 4 files changed, 99 insertions(+), 119 deletions(-) delete mode 100644 packages/core/src/plugin/boot.ts diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index cdaefe2cff1..89ca9500257 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -7,7 +7,7 @@ import { Catalog } from "./catalog" import { Integration } from "./integration" import { CommandV2 } from "./command" import { AgentV2 } from "./agent" -import { PluginBoot } from "./plugin/boot" +import { PluginInternal } from "./plugin/internal" import { Project } from "./project" import { ProjectCopy } from "./project/copy" import { ProjectDirectories } from "./project/directories" @@ -65,7 +65,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Integration.locationLayer, CommandV2.locationLayer, AgentV2.locationLayer, - PluginBoot.locationLayer, + PluginInternal.locationLayer, ProjectCopy.locationLayer, FileSystem.locationLayer, Watcher.locationLayer, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts deleted file mode 100644 index 34b52417d8c..00000000000 --- a/packages/core/src/plugin/boot.ts +++ /dev/null @@ -1,101 +0,0 @@ -export * as PluginBoot from "./boot" - -import { Effect, Layer } from "effect" -import { Integration } from "../integration" -import { AgentV2 } from "../agent" -import { AISDK } from "../aisdk" -import { Catalog } from "../catalog" -import { CommandV2 } from "../command" -import { Config } from "../config" -import { ConfigAgentPlugin } from "../config/plugin/agent" -import { ConfigCommandPlugin } from "../config/plugin/command" -import { ConfigSkillPlugin } from "../config/plugin/skill" -import { ConfigReferencePlugin } from "../config/plugin/reference" -import { ConfigExternalPlugin } from "../config/plugin/external" -import { EventV2 } from "../event" -import { FSUtil } from "../fs-util" -import { FileSystem } from "../filesystem" -import { Global } from "../global" -import { Location } from "../location" -import { ModelsDev } from "../models-dev" -import { Npm } from "../npm" -import { PluginV2 } from "../plugin" -import { AgentPlugin } from "./agent" -import { CommandPlugin } from "./command" -import { SkillPlugin } from "./skill" -import { ConfigProviderPlugin } from "../config/plugin/provider" -import { ModelsDevPlugin } from "./models-dev" -import { ProviderPlugins } from "./provider" -import { SkillV2 } from "../skill" -import { Reference } from "../reference" -import { State } from "../state" -import { PluginHost } from "./host" -import { PluginInternal } from "./internal" - -export const locationLayer = Layer.effectDiscard( - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const commands = yield* CommandV2.Service - const plugin = yield* PluginV2.Service - const integration = yield* Integration.Service - const agents = yield* AgentV2.Service - const config = yield* Config.Service - const location = yield* Location.Service - const modelsDev = yield* ModelsDev.Service - const npm = yield* Npm.Service - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const filesystem = yield* FileSystem.Service - const global = yield* Global.Service - const skill = yield* SkillV2.Service - const reference = yield* Reference.Service - const host = yield* PluginHost.make(plugin) - - const add = (input: PluginInternal.Plugin) => - input - .effect({ ...host, options: {} }) - .pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Integration.Service, integration), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(FileSystem.Service, filesystem), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, reference), - ) - - yield* State.batch( - Effect.gen(function* () { - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - yield* add(ModelsDevPlugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) - for (const item of ProviderPlugins) yield* add(item) - yield* add(ConfigExternalPlugin.Plugin) - }), - ).pipe(Effect.withSpan("PluginBoot.boot")) - }), -).pipe( - Layer.provideMerge(PluginV2.locationLayer), - Layer.provideMerge(AISDK.locationLayer), - Layer.provideMerge(Integration.locationLayer), - Layer.provideMerge(Catalog.locationLayer), - Layer.provideMerge(CommandV2.locationLayer), - Layer.provideMerge(Config.locationLayer), - Layer.provideMerge(AgentV2.locationLayer), - Layer.provideMerge(SkillV2.locationLayer), - Layer.provideMerge(Reference.locationLayer), - Layer.provideMerge(FileSystem.locationLayer), -) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index ba7e248f684..9b66a7b6ad9 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,21 +1,35 @@ export * as PluginInternal from "./internal" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" -import type { Effect, Scope } from "effect" -import type { AgentV2 } from "../agent" -import type { Catalog } from "../catalog" -import type { CommandV2 } from "../command" -import type { Config } from "../config" -import type { EventV2 } from "../event" -import type { FileSystem } from "../filesystem" -import type { FSUtil } from "../fs-util" -import type { Global } from "../global" -import type { Integration } from "../integration" -import type { Location } from "../location" -import type { ModelsDev } from "../models-dev" -import type { Npm } from "../npm" -import type { Reference } from "../reference" -import type { SkillV2 } from "../skill" +import { Effect, Layer, Scope } from "effect" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { Config } from "../config" +import { ConfigAgentPlugin } from "../config/plugin/agent" +import { ConfigCommandPlugin } from "../config/plugin/command" +import { ConfigExternalPlugin } from "../config/plugin/external" +import { ConfigProviderPlugin } from "../config/plugin/provider" +import { ConfigReferencePlugin } from "../config/plugin/reference" +import { ConfigSkillPlugin } from "../config/plugin/skill" +import { EventV2 } from "../event" +import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { Integration } from "../integration" +import { Location } from "../location" +import { ModelsDev } from "../models-dev" +import { Npm } from "../npm" +import { PluginV2 } from "../plugin" +import { Reference } from "../reference" +import { SkillV2 } from "../skill" +import { State } from "../state" +import { AgentPlugin } from "./agent" +import { CommandPlugin } from "./command" +import { PluginHost } from "./host" +import { ModelsDevPlugin } from "./models-dev" +import { ProviderPlugins } from "./provider" +import { SkillPlugin } from "./skill" export type Requirements = | AgentV2.Service @@ -41,3 +55,70 @@ export interface Plugin { export function define(plugin: Plugin) { return plugin } + +export const locationLayer = Layer.effectDiscard( + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const plugin = yield* PluginV2.Service + const integration = yield* Integration.Service + const agents = yield* AgentV2.Service + const config = yield* Config.Service + const location = yield* Location.Service + const modelsDev = yield* ModelsDev.Service + const npm = yield* Npm.Service + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const filesystem = yield* FileSystem.Service + const global = yield* Global.Service + const skill = yield* SkillV2.Service + const reference = yield* Reference.Service + const host = yield* PluginHost.make(plugin) + + const wrap = (input: Plugin) => ({ + id: input.id, + effect: (context: PluginContext) => + input + .effect(context) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), + }) + + yield* State.batch( + Effect.gen(function* () { + yield* plugin.transform((plugins) => { + plugins.add(wrap(AgentPlugin.Plugin)) + plugins.add(wrap(CommandPlugin.Plugin)) + plugins.add(wrap(SkillPlugin.Plugin)) + plugins.add(wrap(ModelsDevPlugin)) + plugins.add(wrap(ConfigProviderPlugin.Plugin)) + plugins.add(wrap(ConfigAgentPlugin.Plugin)) + plugins.add(wrap(ConfigCommandPlugin.Plugin)) + plugins.add(wrap(ConfigSkillPlugin.Plugin)) + plugins.add(wrap(ConfigReferencePlugin.Plugin)) + for (const item of ProviderPlugins) plugins.add(wrap(item)) + }) + + yield* wrap(ConfigExternalPlugin.Plugin).effect(host) + }), + ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + }), +).pipe( + Layer.provideMerge(PluginV2.locationLayer), + Layer.provideMerge(Config.locationLayer), + Layer.provideMerge(FileSystem.locationLayer), +) diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 58fb4ba0dcb..9543220ae8a 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -10,7 +10,7 @@ type HostRegistration = { readonly dispose: Effect.Effect } /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only - * loader (`PluginV2` / `PluginBoot`) can run it unchanged. + * loader (`PluginV2` / `PluginInternal`) can run it unchanged. * * Hook registrations created during the async `setup` attach to the plugin's * scope, so unloading the plugin disposes them. The captured fiber context From 975b1132f1bdfe24caa27e45100f27683cc7748a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 20:10:29 -0400 Subject: [PATCH 097/112] refactor(plugin): use direct runtime registry --- packages/core/src/config/plugin/external.ts | 10 +-- packages/core/src/plugin.ts | 84 ++++++++++----------- packages/core/src/plugin/host.ts | 6 +- packages/core/src/plugin/internal.ts | 82 ++++++++++---------- packages/core/src/plugin/promise.ts | 7 +- packages/core/test/location-layer.test.ts | 29 ++++--- packages/core/test/plugin.test.ts | 35 +++++---- packages/core/test/plugin/host.ts | 4 +- packages/plugin/src/v2/effect/context.ts | 4 +- packages/plugin/src/v2/effect/index.ts | 2 +- packages/plugin/src/v2/effect/plugin.ts | 18 +---- packages/plugin/src/v2/promise/context.ts | 4 +- packages/plugin/src/v2/promise/index.ts | 2 +- packages/plugin/src/v2/promise/plugin.ts | 11 +-- 14 files changed, 130 insertions(+), 168 deletions(-) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index d81d9f7c871..d8ace63b3dd 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -36,12 +36,6 @@ export const Plugin = define({ const fs = yield* FSUtil.Service const location = yield* Location.Service const npm = yield* Npm.Service - const loaded: EffectPlugin[] = [] - - yield* ctx.plugin.transform((plugins) => { - for (const plugin of loaded) plugins.add(plugin) - }) - yield* Effect.gen(function* () { const configured: { package: string; options?: Record }[] = [] @@ -86,14 +80,12 @@ export const Plugin = define({ const mod = yield* Effect.promise(() => import(entrypoint)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) - loaded.push({ + yield* ctx.plugin.add({ id: plugin.id, effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), }) }).pipe(Effect.ignoreCause) } - - yield* ctx.plugin.reload() }).pipe(Effect.forkScoped({ startImmediately: true })) }), }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 85c51ea7dba..bbaa7dead11 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,7 +1,7 @@ export * as PluginV2 from "./plugin" import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" -import type { Plugin, PluginDraft } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" @@ -27,8 +27,8 @@ export const Event = { } export interface Interface { - readonly transform: State.Transform - readonly reload: State.Reload + readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} @@ -40,57 +40,49 @@ export const layer = Layer.effect( const locks = KeyedMutex.makeUnsafe() const scope = yield* Scope.make() const active = new Map() + const loading = new Set() let host: Parameters[0] - const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters[0]) { - const id = ID.make(plugin.id) - yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = active.get(id) - if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) { + if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) - const child = yield* Scope.fork(scope) - yield* plugin.effect(host).pipe( - Scope.provide(child), - Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), - ) - active.set(id, child) - yield* events.publish(Event.Added, { id }) - }), + yield* locks.withLock(id)( + Effect.sync(() => loading.add(id)).pipe( + Effect.andThen( + State.batch( + Effect.gen(function* () { + const existing = active.get(id) + active.delete(id) + if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + + const child = yield* Scope.fork(scope) + yield* effect(host).pipe( + Scope.provide(child), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + ) + active.set(id, child) + yield* events.publish(Event.Added, { id }) + }), + ), + ), + Effect.ensuring(Effect.sync(() => loading.delete(id))), + ), ) }) - const detach = Effect.fn("Plugin.detach")(function* (id: ID) { - yield* locks.withLock(id)( - Effect.gen(function* () { - const current = active.get(id) - active.delete(id) - if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) - }), - ) - }) + const remove = Effect.fn("Plugin.remove")(function* (id: ID) { + if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) - const state = State.create, PluginDraft>({ - initial: () => new Map(), - draft: (draft) => ({ - list: () => Array.from(draft.values()), - add: (plugin) => draft.set(ID.make(plugin.id), plugin), - remove: (id) => draft.delete(ID.make(id)), - }), - finalize: (draft) => + yield* locks.withLock(id)( State.batch( Effect.gen(function* () { - const desired = new Set() - for (const plugin of draft.list()) desired.add(ID.make(plugin.id)) - - for (const id of active.keys()) { - if (!desired.has(id)) yield* detach(id) - } - - for (const plugin of draft.list()) yield* attach(plugin, host) - }).pipe(Effect.withSpan("Plugin.reconcile")), + const current = active.get(id) + active.delete(id) + if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) + }), ), + ) }) yield* Effect.addFinalizer((exit) => @@ -101,8 +93,8 @@ export const layer = Layer.effect( ) const service = Service.of({ - transform: state.transform, - reload: state.reload, + add, + remove, }) host = yield* PluginHost.make(service) return service diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 27afc14d2af..f6c0e24e020 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -8,7 +8,7 @@ import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Integration } from "../integration" import { ModelV2 } from "../model" -import type { PluginV2 } from "../plugin" +import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" import { Reference } from "../reference" import { SkillV2 } from "../skill" @@ -126,8 +126,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, plugin: { - reload: plugin.reload, - transform: plugin.transform, + add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), + remove: (id) => plugin.remove(PluginV2.ID.make(id)), }, reference: { reload: reference.reload, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 9b66a7b6ad9..6aeef4dc3d2 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -23,10 +23,8 @@ import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { Reference } from "../reference" import { SkillV2 } from "../skill" -import { State } from "../state" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" -import { PluginHost } from "./host" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillPlugin } from "./skill" @@ -73,49 +71,45 @@ export const locationLayer = Layer.effectDiscard( const global = yield* Global.Service const skill = yield* SkillV2.Service const reference = yield* Reference.Service - const host = yield* PluginHost.make(plugin) + const add = (input: Plugin) => { + const loaded = { + id: input.id, + effect: (context: PluginContext) => + input + .effect(context) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), + } + return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) + } - const wrap = (input: Plugin) => ({ - id: input.id, - effect: (context: PluginContext) => - input - .effect(context) - .pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Integration.Service, integration), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(FileSystem.Service, filesystem), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, reference), - ), - }) - - yield* State.batch( - Effect.gen(function* () { - yield* plugin.transform((plugins) => { - plugins.add(wrap(AgentPlugin.Plugin)) - plugins.add(wrap(CommandPlugin.Plugin)) - plugins.add(wrap(SkillPlugin.Plugin)) - plugins.add(wrap(ModelsDevPlugin)) - plugins.add(wrap(ConfigProviderPlugin.Plugin)) - plugins.add(wrap(ConfigAgentPlugin.Plugin)) - plugins.add(wrap(ConfigCommandPlugin.Plugin)) - plugins.add(wrap(ConfigSkillPlugin.Plugin)) - plugins.add(wrap(ConfigReferencePlugin.Plugin)) - for (const item of ProviderPlugins) plugins.add(wrap(item)) - }) - - yield* wrap(ConfigExternalPlugin.Plugin).effect(host) - }), - ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + yield* Effect.gen(function* () { + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigReferencePlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }), ).pipe( Layer.provideMerge(PluginV2.locationLayer), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 9543220ae8a..49a19af4c50 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -67,8 +67,11 @@ export function fromPromise(plugin: Plugin) { reload: () => run(host.integration.reload()), }, plugin: { - transform: transform(host.plugin), - reload: () => run(host.plugin.reload()), + add: (input) => { + const child = fromPromise(input) + return run(host.plugin.add(child)) + }, + remove: (id) => run(host.plugin.remove(id)), }, reference: { transform: transform(host.reference), diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 67e811558e5..5bb064b5f81 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -197,22 +197,19 @@ describe("LocationServiceMap", () => { Effect.flatMap((dir) => Effect.gen(function* () { const plugins = yield* PluginV2.Service - yield* plugins.transform((draft) => - draft.add( - define({ - id: "reviewer", - effect: (ctx) => - ctx.agent - .transform((agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code" - item.mode = "subagent" - }) - }) - .pipe(Effect.asVoid), - }), - ), - ) + const reviewer = define({ + id: "reviewer", + effect: (ctx) => + ctx.agent + .transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), + }) + yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect) expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ description: "Reviews code", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index a662ed7ca00..b787c754d8c 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -9,35 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) describe("PluginV2", () => { - it.effect("reconciles transformed plugins", () => + it.effect("adds, replaces, and removes plugins", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const agents = yield* AgentV2.Service let description = "first" - const registration = yield* plugins.transform((draft) => { - draft.add( - define({ - id: "managed", - effect: (ctx) => - ctx.agent - .transform((agents) => - agents.update("configured", (agent) => { - agent.description = description - }), - ) - .pipe(Effect.asVoid), - }), - ) - }) + const managed = () => + define({ + id: "managed", + effect: (ctx) => + ctx.agent + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = description + }), + ) + .pipe(Effect.asVoid), + }) + + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") description = "second" - yield* plugins.reload() + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") - yield* registration.dispose + yield* plugins.remove(PluginV2.ID.make("managed")) expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 02bce652c27..62fa8391e36 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -33,8 +33,8 @@ export function host(overrides: Overrides = {}): PluginContext { reload: () => Effect.die("unused integration.reload"), }, plugin: overrides.plugin ?? { - transform: () => Effect.die("unused plugin.transform"), - reload: () => Effect.die("unused plugin.reload"), + add: () => Effect.die("unused plugin.add"), + remove: () => Effect.die("unused plugin.remove"), }, reference: overrides.reference ?? { transform: () => Effect.die("unused reference.transform"), diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 76ba7967401..9089334ee3b 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" import type { IntegrationHooks } from "./integration.js" -import type { PluginHooks } from "./plugin.js" +import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" @@ -16,7 +16,7 @@ export interface PluginContext { readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload - readonly plugin: PluginHooks & Reload + readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload } diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 928649c0508..f13614a54da 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,3 +1,3 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" -export type { Plugin, PluginDraft } from "./plugin.js" +export type { Plugin } from "./plugin.js" diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 75008d92e0d..7352797b824 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,7 +1,5 @@ import type { Effect, Scope } from "effect" import type { PluginContext } from "./context.js" -import type { PluginOptions } from "../options.js" -import type { Hooks } from "./registration.js" export interface Plugin { readonly id: string @@ -12,17 +10,7 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginRef { - readonly package: string - readonly options?: PluginOptions +export interface PluginDomain { + readonly add: (plugin: Plugin) => Effect.Effect + readonly remove: (id: string) => Effect.Effect } - -export interface PluginDraft { - list(): readonly Plugin[] - add(plugin: Plugin): void - remove(id: string): void -} - -export type PluginHooks = Hooks<{ - transform: PluginDraft -}> diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts index 76ba7967401..9089334ee3b 100644 --- a/packages/plugin/src/v2/promise/context.ts +++ b/packages/plugin/src/v2/promise/context.ts @@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" import type { IntegrationHooks } from "./integration.js" -import type { PluginHooks } from "./plugin.js" +import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" @@ -16,7 +16,7 @@ export interface PluginContext { readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload - readonly plugin: PluginHooks & Reload + readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload } diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 41254707f3d..23394fa012a 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -1,7 +1,7 @@ export type { PluginContext } from "./context.js" export type { PluginOptions } from "../options.js" export { define } from "./plugin.js" -export type { Plugin, PluginDraft, PluginHooks, PluginRef } from "./plugin.js" +export type { Plugin, PluginDomain } from "./plugin.js" export type { Registration, Reload } from "./registration.js" export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index f7ff2ad45c7..ab59fb95fc9 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,6 +1,4 @@ import type { PluginContext } from "./context.js" -import type { PluginDraft, PluginRef } from "../effect/plugin.js" -import type { Hooks } from "./registration.js" export interface Plugin { readonly id: string @@ -11,8 +9,7 @@ export function define(plugin: Plugin) { return plugin } -export type { PluginDraft, PluginRef } - -export type PluginHooks = Hooks<{ - transform: PluginDraft -}> +export interface PluginDomain { + readonly add: (plugin: Plugin) => Promise + readonly remove: (id: string) => Promise +} From ef2357915eef1f10d145abd64754622fc668f02b Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 22 Jun 2026 20:31:36 -0400 Subject: [PATCH 098/112] fix(tui): scope file autocomplete to session (#33458) --- .github/workflows/test.yml | 2 +- packages/tui/src/app.tsx | 11 ++-- .../tui/src/component/prompt/autocomplete.tsx | 51 ++++++++++++------- packages/tui/src/context/location.tsx | 14 +++++ packages/tui/src/context/path-format.tsx | 30 +++-------- packages/tui/src/routes/session/index.tsx | 11 ++-- 6 files changed, 70 insertions(+), 49 deletions(-) create mode 100644 packages/tui/src/context/location.tsx diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5776e1bf242..13a76c0a1f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,7 +65,7 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none + run: GITHUB_ACTIONS=false bun turbo test env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 17a9a554c2e..39aa35993f7 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -35,6 +35,7 @@ import { SDKProvider, useSDK } from "./context/sdk" import { StartupLoading } from "./component/startup-loading" import { SyncProvider, useSync } from "./context/sync" import { DataProvider } from "./context/data" +import { LocationProvider } from "./context/location" import { LocalProvider, useLocal } from "./context/local" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" @@ -303,10 +304,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + + + diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index f2916173da3..2a16017a208 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -13,6 +13,7 @@ import { useData } from "../../context/data" import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useTuiConfig } from "../../config" +import { useLocation } from "../../context/location" import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" @@ -21,6 +22,7 @@ import type { PromptInfo } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" +import type { FileSystemEntry } from "@opencode-ai/sdk/v2" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -94,6 +96,7 @@ export function Autocomplete(props: { const frecency = useFrecency() const tuiConfig = useTuiConfig() const paths = useTuiPaths() + const location = useLocation() const [store, setStore] = createStore({ index: 0, selected: 0, @@ -236,16 +239,18 @@ export function Autocomplete(props: { } } - function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { - const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "") - const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) - const urlObj = pathToFileURL(fullPath) + function createFilePart( + item: FileSystemEntry, + filePath: string, + lineRange?: { startLine: number; endLine?: number }, + ) { + const urlObj = pathToFileURL(filePath) const filename = - lineRange && !item.endsWith("/") - ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` - : item + lineRange && item.type !== "directory" + ? `${item.path}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + : item.path - if (lineRange && !item.endsWith("/")) { + if (lineRange && item.type !== "directory") { urlObj.searchParams.set("start", String(lineRange.startLine)) if (lineRange.endLine !== undefined) { urlObj.searchParams.set("end", String(lineRange.endLine)) @@ -254,10 +259,9 @@ export function Autocomplete(props: { return { filename, - url: urlObj.href, part: { type: "file" as const, - mime: "text/plain", + mime: item.mime, filename, url: urlObj.href, source: { @@ -267,7 +271,7 @@ export function Autocomplete(props: { end: 0, value: "", }, - path: item, + path: item.path, }, }, } @@ -284,7 +288,7 @@ export function Autocomplete(props: { }) function normalizeMentionPath(filePath: string) { - const baseDir = sync.path.directory || paths.cwd + const baseDir = location()?.directory || sync.path.directory || paths.cwd const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) @@ -301,7 +305,11 @@ export function Autocomplete(props: { startLine: input.lineStart, endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined, } - const { filename, part } = createFilePart(item, lineRange) + const { filename, part } = createFilePart( + { path: item, type: "file", mime: "text/plain" }, + input.filePath, + lineRange, + ) const index = store.visible === "@" ? store.index : props.input().cursorOffset setStore("visible", false) @@ -310,17 +318,20 @@ export function Autocomplete(props: { } const [files] = createResource( - () => search(), - async (query) => { + () => ({ query: search(), location: location() }), + async (input) => { if (!store.visible || store.visible === "/") return [] if (referenceMatch()) return [] - const { lineRange, baseQuery } = extractLineRange(query ?? "") + const { lineRange, baseQuery } = extractLineRange(input.query ?? "") // Get files from SDK const result = await sdk.client.v2.fs.find({ query: baseQuery, limit: "20", - location: { workspace: project.workspace.current() }, + location: { + directory: input.location?.directory, + workspace: input.location?.workspaceID ?? project.workspace.current(), + }, }) const options: AutocompleteOption[] = [] @@ -331,7 +342,11 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...result.data.data.map((item): AutocompleteOption => { - const { filename, url, part } = createFilePart(item.path, lineRange) + const { filename, part } = createFilePart( + item, + path.join(result.data.location.directory, item.path), + lineRange, + ) return { display: Locale.truncateMiddle(filename, width), value: filename, diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx new file mode 100644 index 00000000000..0f3fca13594 --- /dev/null +++ b/packages/tui/src/context/location.tsx @@ -0,0 +1,14 @@ +import type { LocationRef } from "@opencode-ai/sdk/v2" +import { createContext, useContext, type Accessor, type ParentProps } from "solid-js" + +const context = createContext>() + +export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) { + return props.location}>{props.children} +} + +export function useLocation() { + const value = useContext(context) + if (!value) throw new Error("Location context must be used within a LocationProvider") + return value +} diff --git a/packages/tui/src/context/path-format.tsx b/packages/tui/src/context/path-format.tsx index 8cf77aab946..52b1bee8970 100644 --- a/packages/tui/src/context/path-format.tsx +++ b/packages/tui/src/context/path-format.tsx @@ -1,31 +1,15 @@ import path from "path" -import { createContext, useContext, type ParentProps } from "solid-js" import { abbreviateHome } from "../runtime" +import { useLocation } from "./location" import { useTuiPaths } from "./runtime" -const context = createContext<{ - path: () => string - format: (input?: string) => string -}>() - -export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { - const paths = useTuiPaths() - return ( - props.path || paths.cwd, - format: (input) => formatPath(input, props.path || paths.cwd, paths.home), - }} - > - {props.children} - - ) -} - export function usePathFormatter() { - const value = useContext(context) - if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") - return value + const paths = useTuiPaths() + const location = useLocation() + return { + path: () => location()?.directory || paths.cwd, + format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home), + } } function formatPath(input: string | undefined, base: string, home: string) { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e3758374c87..11d8b47655e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -80,7 +80,8 @@ import { usePluginRuntime } from "../../plugin/runtime" import { DialogRetryAction } from "../../component/dialog-retry-action" import { getRevertDiffFiles } from "../../util/revert-diff" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" -import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" +import { usePathFormatter } from "../../context/path-format" +import { LocationProvider } from "../../context/location" addDefaultParsers(parsers.parsers) @@ -193,6 +194,10 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) + const location = createMemo(() => { + const current = session() + return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined + }) createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) @@ -1138,7 +1143,7 @@ export function Session() { createEffect(on(() => route.sessionID, toBottom)) return ( - + - + ) } From fbf889db83c83db8f4304279cdb5c0398efb5143 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:57:21 -0500 Subject: [PATCH 099/112] fix(tui): preserve worker rejection handling (#33448) Co-authored-by: Dax Raad --- packages/opencode/src/cli/tui/worker.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 9f33cd5b934..4cf6b2d446b 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -13,6 +13,13 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy Heap.start() +const onUnhandledRejection = (_error: unknown) => {} + +const onUncaughtException = (_error: Error) => {} + +process.on("unhandledRejection", onUnhandledRejection) +process.on("uncaughtException", onUncaughtException) + // Subscribe to global events and forward them via RPC GlobalBus.on("event", (event) => { Rpc.emit("global.event", event) @@ -65,6 +72,8 @@ export const rpc = { async shutdown() { await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) + process.off("unhandledRejection", onUnhandledRejection) + process.off("uncaughtException", onUncaughtException) }, } From d29f5eba92af1da86777714cd0176b917d2f6404 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:18:06 -0500 Subject: [PATCH 100/112] refactor(core): remove shell description input (#32823) --- .../app/e2e/smoke/session-timeline.fixture.ts | 5 ++- .../app/e2e/smoke/session-timeline.spec.ts | 12 +++++ packages/core/src/tool/bash.ts | 3 -- packages/core/test/tool-bash.test.ts | 5 +-- packages/opencode/src/acp/tool.ts | 2 +- packages/opencode/src/cli/cmd/run/tool.ts | 15 +++---- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/tool/shell.ts | 12 +---- packages/opencode/src/tool/shell/prompt.ts | 20 ++------- .../test/cli/run/scrollback.surface.test.ts | 39 +++++++++++++--- .../test/cli/run/session-data.test.ts | 2 - .../test/cli/run/session-replay.test.ts | 1 - packages/opencode/test/session/prompt.test.ts | 1 - .../test/session/snapshot-tool-race.test.ts | 1 - .../__snapshots__/parameters.test.ts.snap | 18 -------- .../opencode/test/tool/parameters.test.ts | 11 ++--- packages/opencode/test/tool/shell.test.ts | 45 +------------------ packages/tui/src/routes/session/index.tsx | 42 ++++++++++------- .../tui/src/routes/session/permission.tsx | 4 +- .../inline-tool-wrap-snapshot.test.tsx.snap | 2 - .../tui/inline-tool-wrap-snapshot.test.tsx | 1 - packages/ui/src/components/basic-tool.tsx | 6 ++- packages/ui/src/components/message-part.tsx | 10 ++--- .../timeline-playground.stories.tsx | 4 +- .../web/src/components/share/content-bash.tsx | 3 +- packages/web/src/components/share/part.tsx | 1 - specs/v2/schema-changelog.md | 16 +++++++ 27 files changed, 124 insertions(+), 161 deletions(-) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 1fc8571db44..5a9933e9986 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -139,7 +139,7 @@ function toolPart( status: "completed", input, output: lorem(index * 23 + partIndex, outputLength), - title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", + title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed", metadata, time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, }, @@ -201,7 +201,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 - ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] + ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), @@ -295,6 +295,7 @@ export const fixture = { .filter(renderable) .map((part) => part.id), ), + expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, }, } diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 925614cc28f..a03a750743d 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => { const expectedMessageIDs = fixture.expected.targetMessageIDs await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) + + const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`) + const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]') + const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(shellSubtitle).toHaveCount(0) + await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "false") + await expect(shellSubtitle).toHaveText("bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "true") + await expect(shellSubtitle).toHaveCount(0) }) }) diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index bd6f175adae..f86cbdd69e5 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -28,9 +28,6 @@ export const Input = Schema.Struct({ .annotate({ description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, }), - description: Schema.String.pipe(Schema.optional).annotate({ - description: "Concise description of the command's purpose", - }), }) const Output = Schema.Struct({ diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 0fb2cd73551..9bbea5f0c3a 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -134,10 +134,9 @@ describe("BashTool", () => { const definitions = yield* toolDefinitions(registry) expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") + expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) - expect( - yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })), - ).toEqual({ + expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, output: { structured: { diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index d0e57cc2ecf..84ad2fdb722 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -266,7 +266,7 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { // For shell tools, surface the actual command as the title so it stays visible // before output lands; non-shell tools keep their model-provided title. function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { - if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName + if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName return fallback || toolName } diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 52b29528b27..9a717ba4e6c 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -623,20 +623,18 @@ function snapQuestion(p: ToolProps): ToolSnapshot { function scrollBashStart(p: ToolProps): string { const cmd = p.input.command ?? "" - const desc = p.input.description || "Shell" const wd = p.input.workdir ?? "" - const dir = wd && wd !== "." ? toolPath(wd) : "" - if (cmd && desc === "Shell" && !dir) { + const formatted = wd && wd !== "." ? toolPath(wd) : "" + const dir = formatted === "." ? "" : formatted + if (cmd && !dir) { return `$ ${cmd}` } - const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc - if (!cmd) { - return `# ${title}` + return dir ? `# Running in ${dir}` : "" } - return `# ${title}\n$ ${cmd}` + return `# Running in ${dir}\n$ ${cmd}` } function scrollBashProgress(p: ToolProps): string { @@ -968,11 +966,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo { } function permBash(p: ToolPermissionProps): ToolPermissionInfo { - const title = p.input.description || "Shell command" const cmd = p.input.command || "" return { icon: "#", - title, + title: "Shell command", lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`), } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dad796c998a..a1f4d95c33c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -542,7 +542,7 @@ export const layer = Layer.effect( time: { ...part.state.time, end: completed }, input: part.state.input, title: "", - metadata: { output, description: "" }, + metadata: { output }, output, } yield* sessions.updatePart(part) @@ -569,7 +569,7 @@ export const layer = Layer.effect( Effect.gen(function* () { output += chunk if (part.state.status === "running") { - part.state.metadata = { output, description: "" } + part.state.metadata = { output } yield* sessions.updatePart(part) } }), diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 620378dc104..1d3fb075fb6 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -263,7 +263,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole const ask = Effect.fn("ShellTool.ask")(function* ( ctx: Tool.Context, scan: Scan, - input: { command: string; description: string }, + input: { command: string }, ) { if (scan.dirs.size > 0) { const directories = Array.from(scan.dirs) @@ -277,7 +277,6 @@ const ask = Effect.fn("ShellTool.ask")(function* ( always: globs, metadata: { command: input.command, - description: input.description, directories, patterns: globs, }, @@ -291,7 +290,6 @@ const ask = Effect.fn("ShellTool.ask")(function* ( always: Array.from(scan.always), metadata: { command: input.command, - description: input.description, }, }) }) @@ -438,7 +436,6 @@ export const ShellTool = Tool.define( cwd: string env: NodeJS.ProcessEnv timeout: number - description: string }, ctx: Tool.Context, ) { @@ -482,7 +479,6 @@ export const ShellTool = Tool.define( yield* ctx.metadata({ metadata: { output: "", - description: input.description, }, }) @@ -523,7 +519,6 @@ export const ShellTool = Tool.define( ctx.metadata({ metadata: { output: last, - description: input.description, }, }), ), @@ -534,7 +529,6 @@ export const ShellTool = Tool.define( return ctx.metadata({ metadata: { output: last, - description: input.description, }, }) }), @@ -593,11 +587,10 @@ export const ShellTool = Tool.define( output += "\n\n\n" + meta.join("\n") + "\n" } return { - title: input.description, + title: input.command, metadata: { output: last || preview(output), exit: code, - description: input.description, truncated: cut, ...(cut && file ? { outputPath: file } : {}), }, @@ -646,7 +639,6 @@ export const ShellTool = Tool.define( cwd, env: yield* shellEnv(ctx, cwd), timeout, - description: params.description, }, ctx, ) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index bec50d98d9b..b576b772976 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -7,30 +7,22 @@ import { ShellID } from "./id" const PS = new Set(["powershell", "pwsh"]) const CMD = new Set(["cmd"]) -const descriptions = { - bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - powershell: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', - cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', -} - export type Limits = { maxLines: number maxBytes: number } -export function parameterSchema(description: string) { +export function parameterSchema() { return Schema.Struct({ command: Schema.String.annotate({ description: "The command to execute" }), timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), workdir: Schema.optional(Schema.String).annotate({ description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, }), - description: Schema.String.annotate({ description }), }) } -export const Parameters = parameterSchema(descriptions.bash) +export const Parameters = parameterSchema() export type Parameters = Schema.Schema.Type function renderPrompt(template: string, values: Record) { @@ -103,7 +95,6 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -155,7 +146,6 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -205,7 +195,6 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -242,7 +231,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul gitCommandRestriction: "git commands", createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, - parameterDescription: descriptions.cmd, } } if (isPowerShell) { @@ -264,7 +252,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul ## Summary - <1-3 bullet points> '@`, - parameterDescription: descriptions.powershell, } } return { @@ -280,7 +267,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' ## Summary <1-3 bullet points>`, - parameterDescription: descriptions.bash, } } @@ -300,7 +286,7 @@ export function render(name: string, platform: NodeJS.Platform, limits: Limits, createPrInstruction: selected.createPrInstruction, createPrExample: selected.createPrExample, }), - parameters: parameterSchema(selected.parameterDescription), + parameters: parameterSchema(), } } diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index f1500ec44ae..52ff5a354d5 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -589,6 +589,38 @@ test("coalesces same-line tool progress into one snapshot", async () => { } }) +test("omits the current directory from bash titles", async () => { + const out = await setup() + + try { + await out.scrollback.append( + toolCommit({ + tool: "bash", + phase: "start", + toolState: "running", + state: { + status: "running", + input: { + command: "pwd", + workdir: process.cwd(), + }, + time: { start: 1 }, + }, + }), + ) + + const commits = claim(out.renderer) + try { + expect(render(commits)).toContain("$ pwd") + expect(render(commits)).not.toContain("Running in .") + } finally { + destroy(commits) + } + } finally { + out.scrollback.destroy() + } +}) + test("renders completed bash output with one blank line after the command and before the next group", async () => { const out = await setup() @@ -615,7 +647,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1 }, }, @@ -633,7 +664,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1, end: 2 }, }, @@ -645,6 +675,7 @@ test("renders completed bash output with one blank line after the command and be take() const output = lines.join("\n") + expect(output).toContain("# Running in /tmp/demo\n$ git status") expect(output).toContain("$ git status\n\nOn branch demo") expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1") expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1") @@ -677,7 +708,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, time: { start: 1 }, }, @@ -695,7 +725,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"), title: "pwd; ls -la", @@ -755,7 +784,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, time: { start: 1 }, }, @@ -805,7 +833,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"), title: "ls", diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index 705e0250dea..b685fb679f3 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -435,7 +435,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, @@ -490,7 +489,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index 18ae82d4da6..e3356d18313 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -238,7 +238,6 @@ function shellAssistantMessage(id: string, parentID: string): SessionMessages[nu title: "", metadata: { output: "account.ts\n", - description: "", }, time: { start: 200, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 9c22e004102..d049041fa02 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1811,7 +1811,6 @@ unix( yield* llm.tool("bash", { command: 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30', - description: "Print many lines", timeout: 30_000, workdir: path.resolve(dir), }) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 8a3701e1251..98233f3527d 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -139,7 +139,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", { command, - description: "create test file", }) yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index b187b191c1f..51ff867ea44 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -24,23 +24,6 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = ` "description": "The command to execute", "type": "string", }, - "description": { - "description": -"Clear, concise description of what this command does in 5-10 words. Examples: -Input: ls -Output: Lists files in current directory - -Input: git status -Output: Shows working tree status - -Input: npm install -Output: Installs package dependencies - -Input: mkdir foo -Output: Creates directory 'foo'" -, - "type": "string", - }, "timeout": { "description": "Optional timeout in milliseconds", "exclusiveMinimum": 0, @@ -55,7 +38,6 @@ Output: Creates directory 'foo'" }, "required": [ "command", - "description", ], "type": "object", } diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 4e56c61d23c..9c540daad08 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -106,19 +106,16 @@ describe("tool parameters", () => { }) describe("shell", () => { - test("accepts minimum: command + description", () => { - expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + test("accepts command", () => { + expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description", () => { - expect(accepts(Shell, { command: "ls" })).toBe(false) - }) test("rejects missing command", () => { - expect(accepts(Shell, { description: "list" })).toBe(false) + expect(accepts(Shell, {})).toBe(false) }) }) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 2ea1d145896..a3c6ca27bb6 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -182,7 +182,6 @@ describe("tool.shell", () => { Effect.gen(function* () { const result = yield* run({ command: "echo test", - description: "Echo test message", }) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") @@ -204,7 +203,6 @@ describe("tool.shell", () => { const result = yield* bash.execute( { command: "echo fallback", - description: "Echo fallback text", }, ctx, ) @@ -227,7 +225,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo hello", - description: "Echo hello", }, capture(requests), ) @@ -249,7 +246,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo foo && echo bar", - description: "Echo twice", }, capture(requests), ) @@ -273,7 +269,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", }, capture(requests), ) @@ -303,7 +298,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Remove-Item -Recurse tmp", - description: "Remove a temp directory", }, capture(requests, err), ), @@ -331,7 +325,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${file}`, - description: "Read wildcard path", }, capture(requests, err), ), @@ -359,7 +352,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `echo $(cat "${file}")`, - description: "Read nested bash file", }, capture(requests), ) @@ -389,7 +381,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", }, capture(requests, err), ), @@ -415,7 +406,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", }, capture(requests), ) @@ -446,7 +436,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "C:../outside.txt"', - description: "Read drive-relative file", }, capture(requests, err), ), @@ -474,7 +463,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", }, capture(requests, err), ), @@ -503,7 +491,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PWD/../outside.txt"', - description: "Read pwd-relative file", }, capture(requests, err), ), @@ -531,7 +518,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", }, capture(requests, err), ), @@ -567,7 +553,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, - description: "Read Windows ini with missing env", }, capture(requests, err), ), @@ -598,7 +583,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", }, capture(requests), ) @@ -626,7 +610,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", }, capture(requests, err), ), @@ -655,7 +638,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", }, capture(requests, err), ), @@ -682,7 +664,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Set-Location C:/Windows", - description: "Change location", }, capture(requests), ) @@ -710,7 +691,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Output ('a' * 3)", - description: "Write repeated text", }, capture(requests), ) @@ -736,7 +716,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, - description: "Read Windows ini with cmd", }, capture(requests), ) @@ -761,7 +740,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cd ../", - description: "Change to parent directory", }, capture(requests, err), ), @@ -786,7 +764,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: os.tmpdir(), - description: "Echo from temp dir", }, capture(requests, err), ), @@ -817,7 +794,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: dir, - description: "Echo from external dir", }, capture(requests, err), ), @@ -850,7 +826,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: "/tmp", - description: "Echo from Git Bash tmp", }, capture(requests, err), ), @@ -878,7 +853,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", }, capture(requests, err), ), @@ -910,7 +884,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${filepath}`, - description: "Read external file", }, capture(requests, err), ), @@ -922,7 +895,6 @@ describe("tool.shell permissions", () => { expect(extDirReq!.always).toContain(expected) expect(extDirReq!.metadata).toMatchObject({ command: `cat ${filepath}`, - description: "Read external file", directories: [outerTmp], patterns: [expected], }) @@ -942,7 +914,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `rm -rf ${path.join(tmp, "nested")}`, - description: "Remove nested dir", }, capture(requests), ) @@ -963,7 +934,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "git log --oneline -5", - description: "Git log", }, capture(requests), ) @@ -985,7 +955,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "cd .", - description: "Stay in current directory", }, capture(requests), ) @@ -1006,7 +975,7 @@ describe("tool.shell permissions", () => { const requests: Array> = [] expect( yield* fail( - { command: "echo test > output.txt", description: "Redirect test output" }, + { command: "echo test > output.txt" }, capture(requests, err), ), ).toMatchObject({ message: err.message }) @@ -1025,7 +994,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const requests: Array> = [] - yield* run({ command: "ls -la", description: "List" }, capture(requests)) + yield* run({ command: "ls -la" }, capture(requests)) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") @@ -1047,7 +1016,6 @@ describe("tool.shell abort", () => { const res = yield* run( { command: `echo before && sleep 30`, - description: "Long running command", }, { ...ctx, @@ -1078,7 +1046,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `sleep 60`, - description: "Timeout test", timeout: 500, }) expect(result.output).toContain("shell tool terminated command after exceeding timeout") @@ -1099,7 +1066,6 @@ describe("tool.shell abort", () => { const result = yield* tool.execute( { command: `sleep 60`, - description: "Default timeout test", }, ctx, ) @@ -1116,7 +1082,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", }) expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stderr_msg") @@ -1132,7 +1097,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `exit 42`, - description: "Non-zero exit", }) expect(result.metadata.exit).toBe(42) }), @@ -1147,7 +1111,6 @@ describe("tool.shell abort", () => { const result = yield* run( { command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", }, { ...ctx, @@ -1174,7 +1137,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 500 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1190,7 +1152,6 @@ describe("tool.shell truncation", () => { const byteCount = Truncate.MAX_BYTES + 10000 const result = yield* run({ command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1205,7 +1166,6 @@ describe("tool.shell truncation", () => { Effect.gen(function* () { const result = yield* run({ command: fill("lines", 1), - description: "Generate one line", }) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect(result.output).toContain("1") @@ -1220,7 +1180,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 100 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines for file check", }) mustTruncate(result) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 11d8b47655e..b368110f4db 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1994,7 +1994,7 @@ export function InlineToolRow(props: { } function BlockTool(props: { - title: string + title?: string children: JSX.Element onClick?: () => void part?: ToolPart @@ -2023,15 +2023,19 @@ function BlockTool(props: { props.onClick?.() }} > - - {props.title} - - } - > - {props.title.replace(/^# /, "")} + + {(title) => ( + + {title()} + + } + > + {title().replace(/^# /, "")} + + )} {props.children} @@ -2059,15 +2063,15 @@ function Shell(props: ToolProps) { const workdirDisplay = createMemo(() => { const workdir = stringValue(props.input.workdir) if (!workdir || workdir === ".") return undefined - return pathFormatter.format(workdir) + const formatted = pathFormatter.format(workdir) + if (formatted === ".") return undefined + return formatted }) const title = createMemo(() => { - const desc = stringValue(props.input.description) ?? "Shell" const wd = workdirDisplay() - if (!wd) return `# ${desc}` - if (desc.includes(wd)) return `# ${desc}` - return `# ${desc} in ${wd}` + if (!wd) return + return `# Running in ${wd}` }) return ( @@ -2076,11 +2080,15 @@ function Shell(props: ToolProps) { setExpanded((prev) => !prev) : undefined} > - $ {stringValue(props.input.command)} + $ {stringValue(props.input.command)}} + > + {stringValue(props.input.command)} + {limited()} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index c787a84a859..766ed3c116c 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -269,12 +269,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } if (permission === "bash") { - const title = - typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" return { icon: "#", - title, + title: "Shell command", body: ( diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 13110be0bc7..46c48ef325f 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -27,8 +27,6 @@ exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = ` " - # List files - $ ls file.ts diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 00e4ef5c981..8ba730906ae 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -62,7 +62,6 @@ function ShellOutput() { paddingLeft={2} gap={1} > - # List files $ ls file.ts diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 213a48e11f3..2477ff99b52 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,4 @@ -import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" +import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js" import { animate, type AnimationPlaybackControls } from "motion" import { useI18n } from "../context/i18n" import { createStore } from "solid-js/store" @@ -24,7 +24,7 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { export interface BasicToolProps { icon: IconProps["name"] - trigger: TriggerTitle | JSX.Element + trigger: TriggerTitle | JSX.Element | ((open: Accessor) => JSX.Element) children?: JSX.Element status?: string hideDetails?: boolean @@ -89,6 +89,7 @@ export function BasicTool(props: BasicToolProps) { const ready = () => state.ready const pending = () => props.status === "pending" || props.status === "running" const hasChildren = () => (props.defer ? "children" in props : props.children) + const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined let cancelReady: (() => void) | undefined @@ -187,6 +188,7 @@ export function BasicTool(props: BasicToolProps) {

+ {dynamicTrigger} {(title) => (
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index bbc3d2956cb..47ae745e4c6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -422,7 +422,7 @@ export function getToolInfo( return { icon: "console", title: i18n.t("ui.tool.shell"), - subtitle: input.description, + subtitle: input.command, } case "edit": return { @@ -1905,18 +1905,18 @@ ToolRegistry.register({ (
- - + +
- } + )} >
diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 3a59c839a75..a189e3d0f3d 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -316,10 +316,10 @@ const TOOL_SAMPLES = { }, bash: { tool: "bash", - input: { command: "bun test --filter session", description: "Run session tests" }, + input: { command: "bun test --filter session" }, output: "bun test v1.3.14\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", - title: "Run session tests", + title: "bun test --filter session", metadata: { command: "bun test --filter session" }, }, edit: { diff --git a/packages/web/src/components/share/content-bash.tsx b/packages/web/src/components/share/content-bash.tsx index f8130ecc641..14fd6df6c2c 100644 --- a/packages/web/src/components/share/content-bash.tsx +++ b/packages/web/src/components/share/content-bash.tsx @@ -6,7 +6,6 @@ import { codeToHtml } from "shiki" interface Props { command: string output: string - description?: string expand?: boolean } @@ -45,7 +44,7 @@ export function ContentBash(props: Props) {
- {props.description} + Shell
diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 34cfe3f42f4..67099196a07 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -616,7 +616,6 @@ export function BashTool(props: ToolProps) { ) } diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index cb90e305e8b..bfe7efd89c6 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -714,6 +714,22 @@ Compatibility: - Foreground V2 bash execution is unchanged. - Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics. +## 2026-06-18: Remove Bash Description Input + +Affected schema: + +- V1 and Core V2 model-facing `bash` tool parameters. + +Change: + +- Remove the V1 required and V2 optional `description` parameter. +- Derive shell presentation from the command or a generic shell label instead of model-authored description metadata. + +Compatibility: + +- Existing persisted tool calls may still contain `description`, but new tool definitions no longer expose or require it. +- Shell command execution behavior is unchanged. + ## 2026-06-04: Add Durable Session Context Snapshots Affected schema: From 237595e2421473b871f613de87e5588d8aa8acae Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 02:19:31 +0000 Subject: [PATCH 101/112] chore: generate --- packages/app/e2e/smoke/session-timeline.fixture.ts | 4 +--- packages/opencode/src/tool/shell.ts | 6 +----- packages/opencode/test/tool/shell.test.ts | 9 +++------ packages/tui/src/routes/session/index.tsx | 5 +---- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 5a9933e9986..58d50e3312e 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -200,9 +200,7 @@ function turn(index: number): Message[] { ...(index % 8 === 0 ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), - ...(index % 7 === 0 - ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] - : []), + ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), ...(index % 13 === 0 diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1d3fb075fb6..1e4423e0177 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -260,11 +260,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole return tree }) -const ask = Effect.fn("ShellTool.ask")(function* ( - ctx: Tool.Context, - scan: Scan, - input: { command: string }, -) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) { if (scan.dirs.size > 0) { const directories = Array.from(scan.dirs) const globs = directories.map((dir) => { diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index a3c6ca27bb6..7b98e721d36 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -973,12 +973,9 @@ describe("tool.shell permissions", () => { Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - expect( - yield* fail( - { command: "echo test > output.txt" }, - capture(requests, err), - ), - ).toMatchObject({ message: err.message }) + expect(yield* fail({ command: "echo test > output.txt" }, capture(requests, err))).toMatchObject({ + message: err.message, + }) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("echo test > output.txt") diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b368110f4db..b57a1e5ddae 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2083,10 +2083,7 @@ function Shell(props: ToolProps) { onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} > - $ {stringValue(props.input.command)}} - > + $ {stringValue(props.input.command)}}> {stringValue(props.input.command)} From d2c866bf701e6ef96a748c5be4a4ffd3f9f0b8dc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 22:49:08 -0400 Subject: [PATCH 102/112] test(core): speed up test setup --- packages/core/test/preload.ts | 4 ++++ packages/core/test/process/process.test.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts index 8a7fd8ca7f2..39b237d70a4 100644 --- a/packages/core/test/preload.ts +++ b/packages/core/test/preload.ts @@ -1 +1,5 @@ +import path from "path" + process.env.OPENCODE_DB = ":memory:" +process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json") +process.env.OPENCODE_DISABLE_MODELS_FETCH = "true" diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index f8377f718aa..92511241409 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -144,7 +144,7 @@ describe("AppProcess", () => { const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)` return Effect.gen(function* () { const svc = yield* AppProcess.Service - const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "1 second" })) + const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" })) expect(Exit.isFailure(exit)).toBe(true) expect(yield* waitForFile(ready)).toMatch(/^\d+$/) expect(yield* waitForFile(settled)).toBe("settled") From 4a710e467997e27405956a80d80fce4eda774178 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 22:49:08 -0400 Subject: [PATCH 103/112] fix(core): await plugin readiness --- packages/core/src/plugin.ts | 53 +++++++++++++++++-- packages/core/test/plugin.test.ts | 30 ++++++++++- packages/opencode/src/agent/agent.ts | 2 + packages/opencode/src/session/system.ts | 2 + .../test/server/httpapi-reference.test.ts | 21 +++++--- 5 files changed, 98 insertions(+), 10 deletions(-) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index bbaa7dead11..4bb40934a21 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,6 +1,6 @@ export * as PluginV2 from "./plugin" -import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" +import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" @@ -29,6 +29,7 @@ export const Event = { export interface Interface { readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect readonly remove: (id: ID) => Effect.Effect + readonly wait: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} @@ -41,13 +42,18 @@ export const layer = Layer.effect( const scope = yield* Scope.make() const active = new Map() const loading = new Set() + const waiters = new Map>>() + const failures = new Map>() let host: Parameters[0] const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) { if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) yield* locks.withLock(id)( - Effect.sync(() => loading.add(id)).pipe( + Effect.sync(() => { + loading.add(id) + failures.delete(id) + }).pipe( Effect.andThen( State.batch( Effect.gen(function* () { @@ -61,11 +67,22 @@ export const layer = Layer.effect( Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), ) - active.set(id, child) yield* events.publish(Event.Added, { id }) + active.set(id, child) + yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { + discard: true, + }) + waiters.delete(id) }), ), ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return Effect.void + failures.set(id, exit) + return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), { + discard: true, + }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id)))) + }), Effect.ensuring(Effect.sync(() => loading.delete(id))), ), ) @@ -79,12 +96,41 @@ export const layer = Layer.effect( Effect.gen(function* () { const current = active.get(id) active.delete(id) + failures.delete(id) if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) }), ), ) }) + const wait = Effect.fn("Plugin.wait")(function* (id: ID) { + const waiter = yield* Deferred.make() + const pending = yield* locks.withLock(id)( + Effect.sync(() => { + if (active.has(id)) return false + const failure = failures.get(id) + if (failure) return failure + const current = waiters.get(id) ?? new Set() + current.add(waiter) + waiters.set(id, current) + return true + }), + ) + if (!pending) return + if (typeof pending !== "boolean") return yield* pending + yield* Deferred.await(waiter).pipe( + Effect.ensuring( + locks.withLock(id)( + Effect.sync(() => { + const current = waiters.get(id) + current?.delete(waiter) + if (current?.size === 0) waiters.delete(id) + }), + ), + ), + ) + }) + yield* Effect.addFinalizer((exit) => Effect.gen(function* () { active.clear() @@ -95,6 +141,7 @@ export const layer = Layer.effect( const service = Service.of({ add, remove, + wait, }) host = yield* PluginHost.make(service) return service diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index b787c754d8c..1e278976539 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Exit, Fiber } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -9,6 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) describe("PluginV2", () => { + it.effect("waits for a plugin and returns immediately once active", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const id = PluginV2.ID.make("waited") + const waiting = yield* plugins.wait(id).pipe(Effect.forkChild) + + yield* plugins.add(id, () => Effect.void) + yield* Fiber.join(waiting) + yield* plugins.wait(id) + }), + ) + + it.effect("propagates plugin activation defects to waiters", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const id = PluginV2.ID.make("failed") + const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild) + + const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit) + const pending = yield* Fiber.join(waiting) + const later = yield* plugins.wait(id).pipe(Effect.exit) + + expect(Exit.isFailure(added)).toBe(true) + expect(Exit.isFailure(pending)).toBe(true) + expect(Exit.isFailure(later)).toBe(true) + }), + ) + it.effect("adds, replaces, and removes plugins", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8e6480538ed..dfb838fac3c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -30,6 +30,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" export const Info = Schema.Struct({ name: Schema.String, @@ -99,6 +100,7 @@ export const layer = Layer.effect( const cfg = yield* config.get() const skillDirs = yield* skill.dirs() const referenceDirs = yield* Effect.gen(function* () { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) const whitelistedDirs = [ diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 49b79018579..35ef47ab122 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -20,6 +20,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Reference } from "@opencode-ai/core/reference" +import { PluginV2 } from "@opencode-ai/core/plugin" export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) @@ -54,6 +55,7 @@ export const layer = Layer.effect( environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { const ctx = yield* InstanceState.context const references = yield* Effect.gen(function* () { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) return [ diff --git a/packages/opencode/test/server/httpapi-reference.test.ts b/packages/opencode/test/server/httpapi-reference.test.ts index dcb260588b7..bf615126816 100644 --- a/packages/opencode/test/server/httpapi-reference.test.ts +++ b/packages/opencode/test/server/httpapi-reference.test.ts @@ -4,6 +4,8 @@ import { Server } from "../../src/server/server" import { Global } from "@opencode-ai/core/global" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { Effect } from "effect" +import { pollWithTimeout } from "../lib/effect" afterEach(async () => { await disposeAllInstances() @@ -24,12 +26,19 @@ describe("reference HttpApi", () => { }, }) - const response = await Server.Default().app.request("/api/reference", { - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - const body = await response.json() + const body = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await Server.Default().app.request("/api/reference", { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + const body = await response.json() + return body.data.length === 0 ? undefined : body + }), + "references were not loaded", + ), + ) expect(body).toMatchObject({ location: { directory: tmp.path } }) expect(body.data).toEqual([ { From af14fefc96b11ecb0c53ac63ce37f5b3cf740eb7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 23:01:05 -0400 Subject: [PATCH 104/112] test(opencode): relax compaction readiness timeout --- packages/opencode/test/session/compaction.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 63276bfe197..f2ee0b65d72 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1250,7 +1250,7 @@ describe("session.compaction.process", () => { }) .pipe(Effect.forkChild) - yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + yield* Deferred.await(ready).pipe(Effect.timeout("5 seconds")) const start = Date.now() yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) @@ -1263,6 +1263,7 @@ describe("session.compaction.process", () => { }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, + { timeout: 10_000 }, ) itCompaction.instance( From ed75ce9eccb6d97a4ee0f79d3db8cb33b47b0661 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 23:39:26 -0400 Subject: [PATCH 105/112] fix(core): prioritize reference plugin registration --- packages/core/src/plugin/internal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 6aeef4dc3d2..e170a85f11d 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -98,6 +98,7 @@ export const locationLayer = Layer.effectDiscard( } yield* Effect.gen(function* () { + yield* add(ConfigReferencePlugin.Plugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) yield* add(SkillPlugin.Plugin) @@ -106,7 +107,6 @@ export const locationLayer = Layer.effectDiscard( yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) for (const item of ProviderPlugins) yield* add(item) yield* add(ConfigExternalPlugin.Plugin) }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) From 6ea3e6698add79d85d731515fa4365d87e0216ab Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 00:10:36 -0400 Subject: [PATCH 106/112] fix(opencode): scope reference readiness wait --- packages/opencode/src/agent/agent.ts | 10 ++++++---- packages/opencode/src/session/system.ts | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index dfb838fac3c..cb239381327 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -99,10 +99,12 @@ export const layer = Layer.effect( Effect.fn("Agent.state")(function* (ctx) { const cfg = yield* config.get() const skillDirs = yield* skill.dirs() - const referenceDirs = yield* Effect.gen(function* () { - yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) - return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) - }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) + const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length + ? yield* Effect.gen(function* () { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) + return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) + }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) + : [] const whitelistedDirs = [ Truncate.GLOB, path.join(Global.Path.tmp, "*"), diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 35ef47ab122..49b79018579 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -20,7 +20,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Reference } from "@opencode-ai/core/reference" -import { PluginV2 } from "@opencode-ai/core/plugin" export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) @@ -55,7 +54,6 @@ export const layer = Layer.effect( environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { const ctx = yield* InstanceState.context const references = yield* Effect.gen(function* () { - yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) return [ From 3c5632e110780cb5f1f923ad8d0b35866457f31a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 00:26:48 -0400 Subject: [PATCH 107/112] test(opencode): stabilize Windows CLI subprocesses --- packages/opencode/test/cli/help/help-snapshots.test.ts | 3 +-- packages/opencode/test/lib/cli-process.ts | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index a2626b113da..3a14d0d7ecc 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -13,7 +13,6 @@ // version (changes per release), so we'd snapshot a moving target. import { describe, expect } from "bun:test" import { Effect } from "effect" -import { EOL } from "os" import { cliIt } from "../../lib/cli-process" import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot" @@ -101,7 +100,7 @@ describe("opencode CLI help-text snapshots", () => { Effect.gen(function* () { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) - expect(topLevel.stderr.endsWith(EOL)).toBe(true) + expect(topLevel.stderr.endsWith("\n")).toBe(true) expect(topLevel.stderr).toContain("--mini") expect(topLevel.stderr).not.toContain("--thinking") expect(topLevel.stderr).not.toContain("--variant") diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 6b16ab74b54..dd03c786a45 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -517,5 +517,10 @@ export const cliIt = { name: string, body: (input: CliFixture) => Effect.Effect, opts?: number | TestOptions, - ) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts), + ) => + (process.platform === "win32" ? test : test.concurrent)( + name, + () => Effect.runPromise(Effect.scoped(withCliFixture(body))), + opts, + ), } From 81851ca6b9c22bc340abe52691eab666be26dd50 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 00:41:02 -0400 Subject: [PATCH 108/112] test(opencode): stabilize Windows async readiness --- .../opencode/test/server/httpapi-file.test.ts | 20 ++++++++++++++----- packages/opencode/test/session/prompt.test.ts | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 55377f84771..ed882ade465 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Context } from "effect" +import { Context, Effect } from "effect" import path from "path" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { pollWithTimeout } from "../lib/effect" const context = Context.empty() as Context.Context @@ -55,17 +56,26 @@ describe("file HttpApi", () => { await using tmp = await tmpdir({ git: true }) await Bun.write(path.join(tmp.path, "hello.txt"), "needle") - const [text, files, symbols] = await Promise.all([ + const [text, symbols] = await Promise.all([ request(FilePaths.findText, tmp.path, { pattern: "needle" }), - request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }), request(FilePaths.findSymbol, tmp.path, { query: "hello" }), ]) + const files = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }) + const body = await response.json() + return body.includes("hello.txt") ? { response, body } : undefined + }), + "file search index was not ready", + ), + ) expect(text.status).toBe(200) expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) - expect(files.status).toBe(200) - expect(await files.json()).toContain("hello.txt") + expect(files.response.status).toBe(200) + expect(files.body).toContain("hello.txt") expect(symbols.status).toBe(200) expect(await symbols.json()).toEqual([]) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index d049041fa02..a01cc9d1231 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1630,7 +1630,7 @@ it.instance( expect(yield* llm.calls).toBe(1) }), { git: true }, - 3_000, + 10_000, ) it.instance( From 9b01f15f1d9dbd6b9ad6035255cd040d8f9a8ec3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 00:56:14 -0400 Subject: [PATCH 109/112] test(opencode): retry Windows CLI cleanup --- packages/opencode/test/lib/cli-process.ts | 12 ++++++++---- packages/opencode/test/server/httpapi-sdk.test.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index dd03c786a45..d3a4493ce17 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -20,7 +20,7 @@ import { test, type TestOptions } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" -import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect" +import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcess } from "effect/unstable/process" import path from "node:path" @@ -192,9 +192,13 @@ export function withCliFixture( const fs = yield* FSUtil.Service const appProc = yield* AppProcess.Service - // FileSystem.makeTempDirectoryScoped handles both creation and scope-tied - // cleanup — replaces the old mkdir + addFinalizer pair. - const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" }) + const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" }) + yield* Effect.addFinalizer(() => + fs.remove(home, { recursive: true }).pipe( + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), + Effect.ignore, + ), + ) const configJson = JSON.stringify(testProviderConfig(llm.url)) const env = isolatedEnv(home, configJson) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 895659a6f7c..63cc3edb2c1 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -22,7 +22,7 @@ import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" -import { awaitWithTimeout, testEffect } from "../lib/effect" +import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -389,7 +389,12 @@ describe("HttpApi SDK", () => { workspaceID, onRequest: (value) => (request = value), }) - const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" })) + const found = yield* pollWithTimeout( + call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe( + Effect.map((result) => (result.data?.data.length ? result : undefined)), + ), + "SDK file search index was not ready", + ) const url = new URL(request!.url) expect(found.response.status).toBe(200) From 40db33c4153bbea5776f90f707a48129b3b2d872 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 04:57:55 +0000 Subject: [PATCH 110/112] chore: generate --- packages/opencode/test/lib/cli-process.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index d3a4493ce17..6de9033ffe3 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -194,10 +194,9 @@ export function withCliFixture( const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" }) yield* Effect.addFinalizer(() => - fs.remove(home, { recursive: true }).pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), - Effect.ignore, - ), + fs + .remove(home, { recursive: true }) + .pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore), ) const configJson = JSON.stringify(testProviderConfig(llm.url)) From 0c4f508c507dc34e335f9ce8ff2f12f5fe50c5df Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:19:18 +0800 Subject: [PATCH 111/112] feat(app): add server-keyed session routes (#32570) --- .../app/e2e/smoke/session-timeline.fixture.ts | 2 + .../app/e2e/smoke/session-timeline.spec.ts | 3 +- packages/app/src/app.tsx | 261 ++++++++++---- .../app/src/components/prompt-input/submit.ts | 7 +- packages/app/src/components/titlebar.tsx | 103 +++--- packages/app/src/context/comments.tsx | 5 +- packages/app/src/context/file.tsx | 3 +- packages/app/src/context/layout.tsx | 16 +- packages/app/src/context/notification.tsx | 8 +- packages/app/src/context/permission.tsx | 8 +- packages/app/src/context/prompt.tsx | 6 +- packages/app/src/context/tabs.tsx | 28 +- packages/app/src/context/terminal.tsx | 12 +- packages/app/src/pages/directory-layout.tsx | 30 +- packages/app/src/pages/home.tsx | 22 +- packages/app/src/pages/layout-new.tsx | 38 +++ packages/app/src/pages/layout.tsx | 319 ++++++++---------- packages/app/src/pages/session.tsx | 12 +- .../composer/session-composer-region.tsx | 7 +- .../app/src/pages/session/session-layout.ts | 14 +- .../app/src/pages/session/terminal-panel.tsx | 8 +- .../session/timeline/message-timeline.tsx | 17 +- .../pages/session/use-session-commands.tsx | 11 +- packages/app/src/utils/session-route.test.ts | 39 +++ packages/app/src/utils/session-route.ts | 25 ++ 25 files changed, 628 insertions(+), 376 deletions(-) create mode 100644 packages/app/src/pages/layout-new.tsx create mode 100644 packages/app/src/utils/session-route.test.ts create mode 100644 packages/app/src/utils/session-route.ts diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 58d50e3312e..3dce37cafd9 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,6 +21,7 @@ const words = [ "vector", ] +const serverKey = "http://127.0.0.1:4096" const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" @@ -240,6 +241,7 @@ function orderedParts(message: Message) { export const fixture = { directory, + serverKey, project: { id: projectID, worktree: directory, diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index a03a750743d..dba7eae7e36 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -718,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin } async function switchTitlebarSession(page: Page, sessionID: string, title: string) { - const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` + console.log(process.env) + const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() await expect(tab).toBeVisible() await tab.click() diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 75f0c6b4446..e2f6108fd2b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" -import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query" import { Effect } from "effect" import { type Component, @@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web" import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" -import { ServerSDKProvider } from "@/context/server-sdk" +import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" import { GlobalProvider } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" @@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" -import Layout from "@/pages/layout" +import LegacyLayout from "@/pages/layout" +import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" +import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route" -const HomeRoute = lazy(() => import("@/pages/home")) +const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome }))) +const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome }))) const Session = lazy(() => import("@/pages/session")) const NewSession = lazy(() => import("@/pages/new-session")) @@ -64,6 +67,10 @@ const SessionRoute = Object.assign( const server = useServer() const tabs = useTabs() + if (params.id && settings.general.newLayoutDesigns()) { + return + } + // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) // is replaced by a draft at /new-session?draftId=… createEffect(() => { @@ -82,29 +89,55 @@ const SessionRoute = Object.assign( { preload: Session.preload }, ) +const TargetSessionRoute = Object.assign( + () => { + const sdk = useSDK() + const serverSDK = useServerSDK() + return ( + + + + + + ) + }, + { preload: Session.preload }, +) + // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // server via ServerKey, then provide the server-scoped shell (Permission/Layout/ // Notification/Models + the visual Layout) for that server. -function SelectedServerLayout(props: ParentProps) { +function SelectedServerProviders(props: ParentProps) { return ( - - {props.children} - + {props.children} ) } +function LegacyServerLayout(props: ParentProps) { + return ( + + {props.children} + + ) +} + // Wraps /new-session. It resolves the draft's target server and provides the // server-scoped shell for that server — without ServerKey, so the page never depends // on the globally "selected" server. -function DraftServerLayout(props: ParentProps) { +function TargetServerLayout(props: ParentProps) { const server = useServer() const tabs = useTabs() + const params = useParams<{ serverKey?: string }>() const [search] = useSearchParams<{ draftId?: string }>() const conn = createMemo(() => { + if (params.serverKey) { + const key = requireServerKey(params.serverKey) + return server.list.find((item) => ServerConnection.key(item) === key) + } const id = search.draftId if (!id) return undefined const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) @@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) { return ( - {props.children} + {props.children} ) } +function TargetDirectoryLayout(props: ParentProps) { + const params = useParams<{ serverKey?: string; id?: string }>() + const [search] = useSearchParams<{ draftId?: string }>() + const settings = useSettings() + const tabs = useTabs() + const serverSDK = useServerSDK() + const serverKey = createMemo(() => { + if (params.serverKey) return requireServerKey(params.serverKey) + if (!search.draftId) return undefined + return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server + }) + + const resolved = useQuery(() => ({ + queryKey: [serverSDK().scope, "session-route", params.id] as const, + enabled: !!params.serverKey && !!params.id, + placeholderData: keepPreviousData, + queryFn: async () => { + const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data! + const root = await rootSession(session, (sessionID) => + serverSDK() + .client.session.get({ sessionID }) + .then((result) => result.data!), + ) + return { session, rootID: root.id } + }, + })) + const resolvedDirectory = createMemo(() => { + if (params.serverKey) return resolved.data?.session.directory + if (!search.draftId) return undefined + return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory + }) + const directory = createMemo((prev) => prev ?? resolvedDirectory()) + const home = () => !params.serverKey && !search.draftId + const targetDirectory = () => directory()! + + createEffect(() => { + const current = resolved.data + const key = serverKey() + if (!current || !key) return + tabs.addSessionTab({ + server: key, + sessionId: current.rootID, + }) + }) + + return ( + (home() ? undefined : directory())} sessionID={() => params.id}> + + }> + + } + > + + + + {props.children} + + + + + + + + + ) +} + function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() return ( }> - {(draftID) => } + ) } -function ResolvedDraftRoute(props: { draftID: string }) { - const tabs = useTabs() - const draft = createMemo(() => - tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID), - ) - - // Key on the directory so retargeting the draft's project re-instantiates the - // directory-scoped providers while keeping the same draft id. The draft's target - // server is provided by DraftServerLayout, so changing only the server updates the - // SDK/sync hooks without remounting the composer. - const directory = () => draft()?.directory - +function ResolvedDraftRoute() { return ( - - {(dir) => ( - - - - - - - - )} - + + + ) } @@ -210,32 +293,51 @@ function BodyDesignClass() { // shell (router root) so they stay mounted regardless of the active server/route. function SharedProviders(props: ParentProps) { return ( - + <> {props.children} - + ) } // Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside // each per-route server layout so they resolve to that route's server (selected vs // draft). The Layout remounts when crossing between those groups. -function ServerScopedShell(props: ParentProps) { +type ServerScopedShellProps = ParentProps<{ + directory?: () => string | undefined + sessionID?: () => string | undefined +}> + +function ServerScopedProviders(props: ServerScopedShellProps) { return ( - + - - - {props.children} - + + {props.children} ) } +function LegacyServerScopedShell(props: ServerScopedShellProps) { + return ( + + {props.children} + + ) +} + +function NewServerScopedShell(props: ServerScopedShellProps) { + return ( + + {props.children} + + ) +} + function SessionProviders(props: ParentProps) { return ( @@ -439,28 +541,61 @@ export function AppInterface(props: { servers={props.servers} > - - ( - - {routerProps.children} - - )} - > - - - - } /> - - - - - - - - + + + + ( + + {routerProps.children} + + )} + > + + + + + ) } + +function Routes() { + const settings = useSettings() + + return ( + <> + + {} + + } /> + + + + + + { + <> + + + { + const server = useServer() + const { id } = useParams() + + return + }} + /> + + + } + + + + + + ) +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index a95f0a60307..621723ae110 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { local.session.promote(sessionDirectory, session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) const draftID = search.draftId - if (draftID) - tabs.promoteDraft(draftID, { - server: server.key, - dirBase64: base64Encode(sessionDirectory), - sessionId: session.id, - }) + if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id }) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 82771432041..3312856391f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" -import { useServerSync } from "@/context/server-sync" import { base64Encode } from "@opencode-ai/core/util/encode" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" @@ -38,10 +37,11 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { useGlobal } from "@/context/global" -import { decode64 } from "@/utils/base64" import { ServerConnection, useServer } from "@/context/server" -import { tabHref, useTabs, type Tab } from "@/context/tabs" +import { tabHref, useTabs } from "@/context/tabs" import "./titlebar.css" +import { useServerSDK } from "@/context/server-sdk" +import { Session } from "@opencode-ai/sdk/v2" type TauriDesktopWindow = { startDragging?: () => Promise @@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { {(_) => { - const serverSync = useServerSync() + const serverSdk = useServerSDK() const navigate = useNavigate() const layout = useLayout() @@ -268,6 +268,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs + const [session] = createResource( + () => { + const route = layout.route() + return route.type === "session" ? route : undefined + }, + (route) => + serverSdk() + .client.session.get({ sessionID: route.sessionId }) + .then((x) => x.data) + .catch(() => {}), + ) const matchRoute = (route: LayoutRoute) => { if (route.type === "home") return @@ -280,10 +291,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, ) if (main) return main - const sync = serverSync().createDirSyncContext(route.dir) - const session = sync.session.get(route.sessionId) - if (session?.parentID) { - const parentID = session.parentID + const s = session() + if (s?.parentID) { + const parentID = s.parentID const parent = tabsStore.find( (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, ) @@ -304,15 +314,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { } if (route.type === "session") { - const sync = serverSync().createDirSyncContext(route.dir) - const session = sync.session.get(route.sessionId) - if (!session) return - const sessionId = session.parentID ?? session.id - const next = { - server: route.server ?? server.key, - dirBase64: route.dirBase64, - sessionId, - } + const s = session() + if (!s) return + const sessionId = s.parentID ?? s.id + const next = { server: route.server ?? server.key, sessionId } tabsStoreActions.addSessionTab(next) } }) @@ -495,25 +500,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { ) } + const [session] = createResource( + () => tab.sessionId, + (sessionID) => + serverSdk() + .client.session.get({ sessionID }) + .then((x) => x.data) + .catch(() => undefined), + ) + return ( <> {divider()} - { - tabs.select(tab) + + {(session) => ( + { + tabs.select(tab) - ref.scrollIntoView({ behavior: "instant" }) - }} - onClose={() => tabsStoreActions.removeTab(i())} - active={currentTab() === tab} - activeServer={tab.server === server.key} - forceTruncate={tabsAreOverflowing()} - /> + ref.scrollIntoView({ behavior: "instant" }) + }} + onClose={() => tabsStoreActions.removeTab(i())} + active={currentTab() === tab} + activeServer={tab.server === server.key} + forceTruncate={tabsAreOverflowing()} + /> + )} + ) }} @@ -793,7 +811,6 @@ function TabNavItem(props: { ref?: HTMLDivElement href: string server: ServerConnection.Key - directory: string sessionId?: string hideClose?: boolean onClose: () => void @@ -801,31 +818,19 @@ function TabNavItem(props: { active?: boolean activeServer: boolean forceTruncate?: boolean + session: Session }) { const closeTab = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() props.onClose() } + const global = useGlobal() const serverCtx = createMemo(() => { const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) if (conn) return global.createServerCtx(conn) }) - const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory)) - - const [session] = createResource( - () => { - const ctx = dirSyncCtx() - if (!ctx || !props.sessionId) return - return [props.sessionId, ctx] as const - }, - async ([sessionId, dirSyncCtx]) => { - await dirSyncCtx.session.sync(sessionId).catch(() => {}) - return dirSyncCtx.session.get(sessionId) - }, - { initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined }, - ) return (
- + {(session) => { const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) @@ -853,7 +858,7 @@ function TabNavItem(props: { diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 71186a55f74..afc59b59562 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "./server-sdk" import type { ServerScope } from "@/utils/server-scope" import { createScopedCache } from "@/utils/scoped-cache" import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" +import { useSDK } from "./sdk" export type LineComment = { id: string @@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont gate: false, init: () => { const params = useParams() + const sdk = useSDK() const serverSDK = useServerSDK() const cache = createScopedCache( (key) => { @@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont return cache.get(key).value } - const session = createMemo(() => load(params.dir!, params.id)) + const session = createMemo(() => load(base64Encode(sdk().directory), params.id)) return { ready: () => session().ready(), diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 14ed7466c18..f7668c19492 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { useParams } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" import { getFilename } from "@opencode-ai/core/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const scope = createMemo(() => sdk().directory) const path = createPathHelpers(scope) const tabs = layout.tabs(() => - SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)), + SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)), ) const inflight = new Map>() diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 53bf40e7059..edd58ad7a74 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" +import { requireServerKey } from "@/utils/session-route" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -79,7 +80,7 @@ export type LayoutRoute = | { type: "home" } | { type: "draft"; draftID: string; server?: ServerConnection.Key } | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } - | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } + | { type: "session"; sessionId: string; server?: ServerConnection.Key } function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { const all = current?.all ?? [] @@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => { return { type: "draft", draftID } } + if (parts[0] === "server" && parts[2] === "session" && parts[3]) { + return { + type: "session", + sessionId: parts[3], + server: requireServerKey(parts[1]), + } + } + const dirBase64 = parts[0] const dir = decode64(dirBase64) if (!dir) return { type: "home" } @@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => { if (parts[1] !== "session") return { type: "home" } const id = parts[2] - if (id) return { type: "session", dir, dirBase64, sessionId: id } + if (id) return { type: "session", sessionId: id } return { type: "dir-new-sesssion", dir, dirBase64 } } @@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const route = createMemo(() => { const value = currentRoute(location.pathname, location.search) if (value.type === "home") return value + if (value.server) return value return { ...value, server: server.key } }) @@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( handoff: { tabs: createMemo(() => store.handoff?.tabs), setTabs(dir: string, id: string) { - setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() }) + setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() }) }, clearTabs() { if (!store.handoff?.tabs) return diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 0814dbce7ca..ca0ea5f86c5 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,5 +1,5 @@ import { createStore, reconcile } from "solid-js/store" -import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import { useServerSDK } from "./server-sdk" @@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) { export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", gate: false, - init: () => { + init: (props: { directory?: Accessor; sessionID?: Accessor }) => { const params = useParams() const serverSDK = useServerSDK() const serverSync = useServerSync() @@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const empty: Notification[] = [] const currentDirectory = createMemo(() => { - return decode64(params.dir) + return props.directory?.() ?? decode64(params.dir) }) - const currentSession = createMemo(() => params.id) + const currentSession = createMemo(() => props.sessionID?.() ?? params.id) const [store, setStore, _, ready] = persisted( Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index dce3a404999..ff43638bbc8 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" @@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) { export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ name: "Permission", gate: false, - init: () => { + init: (props: { directory?: Accessor }) => { const params = useParams() const serverSDK = useServerSDK() const serverSync = useServerSync() const permissionsEnabled = createMemo(() => { - const directory = decode64(params.dir) + const directory = props.directory?.() ?? decode64(params.dir) if (!directory) return false const [store] = serverSync().child(directory) return hasPermissionPromptRules(store.config.permission) @@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple // When config has permission: "allow", auto-enable directory-level auto-accept createEffect(() => { if (!ready()) return - const directory = decode64(params.dir) + const directory = props.directory?.() ?? decode64(params.dir) if (!directory) return const [childStore] = serverSync().child(directory) const perm = childStore.config.permission diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 4a62c2a8d8d..62818550da6 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { checksum } from "@opencode-ai/core/util/encode" +import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" @@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "./server-sdk" import type { ServerScope } from "@/utils/server-scope" +import { useSDK } from "./sdk" interface PartBase { content: string @@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( gate: false, init: () => { const params = useParams() + const sdk = useSDK() const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK() const cache = new Map() @@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => - load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }), + load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }), ) const pick = (scope?: Scope) => (scope ? load(scope) : session()) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 393875ff091..1a4a4759787 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -1,6 +1,5 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/core/util/encode" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" @@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" +import { sessionHref } from "@/utils/session-route" export type SessionTab = { type: "session" server: ServerConnection.Key - dirBase64: string sessionId: string } @@ -34,16 +33,12 @@ type RecentTab = { export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const tabHref = (tab: Tab) => - tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` + tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId) export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { - const dirBase64 = base64Encode(session.directory) - return tabs.some( - (tab) => - tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id, - ) + return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) } export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ @@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) - if (tab.server === server.key) { - navigate(href) - return - } - void startTransition(() => { - server.setActive(tab.server) - navigate(href) - }) + navigate(href) } const actions = { @@ -196,10 +184,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const removed = store .filter( (tab) => - tab.type === "session" && - tab.server === server.key && - atob(tab.dirBase64) === input.directory && - input.sessionIDs.includes(tab.sessionId), + tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId), ) .map(tabKey) void startTransition(() => { @@ -211,7 +196,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ ? tabHref({ type: "session", server: server.key, - dirBase64: params.dir, sessionId: params.id, }) : undefined @@ -224,14 +208,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const removedCurrent = currentTab?.type === "session" && currentTab.server === server.key && - atob(currentTab.dirBase64) === input.directory && sessionIDs.has(currentTab.sessionId) for (let i = tabs.length - 1; i >= 0; i--) { const tab = tabs[i] if (!tab || tab.type !== "session") continue if (tab.server !== server.key) continue - if (atob(tab.dirBase64) !== input.directory) continue if (!sessionIDs.has(tab.sessionId)) continue tabs.splice(i, 1) } diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index d1aa61c4ce5..a9c66c48331 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK, type DirectorySDK } from "./sdk" import type { Platform } from "./platform" -import { useServer } from "./server" +import { useServerSDK } from "./server-sdk" +import { base64Encode } from "@opencode-ai/core/util/encode" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" @@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() - const server = useServer() + const serverSDK = useServerSDK() const params = useParams() const cache = new Map() - const scope = server.scope() + const scope = () => serverSDK().scope + const directory = createMemo(() => base64Encode(sdk().directory)) caches.add(cache) onCleanup(() => caches.delete(cache)) @@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) + const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope())) createEffect( on( - () => ({ dir: params.dir, id: params.id, scope }), + () => ({ dir: directory(), id: params.id, scope: scope() }), (next, prev) => { if (!prev?.dir) return if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f937c98facf..d9d5a2edc62 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" +import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" import { Schema } from "effect" +import type { ServerConnection } from "@/context/server" +import { sessionHref } from "@/utils/session-route" -export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { +export function DirectoryDataProvider( + props: ParentProps<{ + directory: string | Accessor + draftID?: string + server?: Accessor + }>, +) { const location = useLocation() const navigate = useNavigate() const params = useParams() const sync = useSync() - const slug = createMemo(() => base64Encode(props.directory)) + const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory) + const slug = createMemo(() => base64Encode(directory())) + const href = (sessionID: string) => { + const server = props.server?.() + if (server) return sessionHref(server, sessionID) + return `/${slug()}/session/${sessionID}` + } createEffect(() => { // A draft lives at /new-session?draftId=… and has no directory segment to normalize. - if (props.draftID) return + if (props.draftID || props.server?.()) return const next = sync().data.path.directory - if (!next || next === props.directory) return + if (!next || next === directory()) return const path = location.pathname.slice(slug().length + 1) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) @@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr return ( navigate(`/${slug()}/session/${sessionID}`)} - onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} + directory={directory()} + onNavigateToSession={(sessionID: string) => navigate(href(sessionID))} + onSessionHref={href} > {props.children} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 24c7e4c3abe..e9f036993ec 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { useGlobal } from "@/context/global" import { useCommand } from "@/context/command" -import { useSettings } from "@/context/settings" import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" import { type ServerHealth } from "@/utils/server-health" @@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) { return `${pathKey(record.session.directory)}:${record.session.id}` } -export default function Home() { - const settings = useSettings() - return ( - }> - - - ) -} - -function HomeDesign() { +export function NewHome() { const sync = useServerSync() const layout = useLayout() const platform = usePlatform() @@ -313,7 +303,7 @@ function HomeDesign() { const ctx = global.createServerCtx(conn) ctx.projects.open(directory) ctx.projects.touch(directory) - navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) + navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`) } function chooseProject(conn: ServerConnection.Any) { @@ -416,7 +406,7 @@ function HomeDesign() { record={record} server={state.selection.server} activeServer={state.selection.server === server.key} - openSession={openSession} + onClick={() => openSession(record.session)} /> )} @@ -1024,7 +1014,7 @@ function HomeSessionRow(props: { record: HomeSessionRecord server: ServerConnection.Key activeServer: boolean - openSession: (session: Session) => void + onClick: () => void }) { const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) @@ -1033,7 +1023,7 @@ function HomeSessionRow(props: { type="button" data-component="home-session-row" class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} - onClick={() => props.openSession(props.record.session)} + onClick={props.onClick} > group.sessions.length > 0) } -function LegacyHome() { +export function LegacyHome() { const sync = useServerSync() const platform = usePlatform() const pickDirectory = useDirectoryPicker() diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx new file mode 100644 index 00000000000..2f8793d8ddd --- /dev/null +++ b/packages/app/src/pages/layout-new.tsx @@ -0,0 +1,38 @@ +import { createEffect, type ParentProps } from "solid-js" +import { useNavigate } from "@solidjs/router" +import { DebugBar } from "@/components/debug-bar" +import { HelpButton } from "@/components/help-button" +import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" +import { usePlatform } from "@/context/platform" +import { setNavigate } from "@/utils/notification-click" +import { setV2Toast, ToastRegion } from "@/utils/toast" + +export default function NewLayout(props: ParentProps) { + const platform = usePlatform() + const navigate = useNavigate() + setNavigate(navigate) + + createEffect(() => setV2Toast(true)) + + const update: TitlebarUpdate = { + version: () => { + const state = platform.updater?.state() + if (state?.status !== "ready") return + return state.version + }, + installing: () => platform.updater?.state().status === "installing", + install: () => void platform.updater?.install(), + } + + return ( +
+ +
+ {props.children} +
+ {import.meta.env.DEV && } + + +
+ ) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4177d4919c1..cb3a4bf6e35 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -13,7 +13,7 @@ import { type Accessor, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useServerSync } from "@/context/server-sync" import { Persist, persisted } from "@/utils/persist" @@ -92,7 +92,7 @@ import { import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" -export default function Layout(props: ParentProps) { +export default function LegacyLayout(props: ParentProps) { const serverSDK = useServerSDK() const [store, setStore, , ready] = persisted( Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), @@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) { const command = useCommand() const theme = useTheme() const language = useLanguage() - const newDesign = createMemo(() => settings.general.newLayoutDesigns()) - createEffect(() => setV2Toast(newDesign())) + createEffect(() => setV2Toast(false)) const initialDirectory = decode64(params.dir) - const location = useLocation() const route = createMemo(() => { const slug = params.dir if (!slug) return { slug, dir: "" } @@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) { const currentDir = createMemo(() => route().dir) const [state, setState] = createStore({ - autoselect: !initialDirectory && !newDesign(), + autoselect: !initialDirectory, busyWorkspaces: {} as Record, hoverProject: undefined as string | undefined, scrollSessionKey: undefined as string | undefined, @@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) { id: "sidebar.toggle", title: language.t("command.sidebar.toggle"), category: language.t("command.category.view"), - keybind: newDesign() ? undefined : "mod+b", + keybind: "mod+b", onSelect: () => layout.sidebar.toggle(), }, { @@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) { }, ] - if (!newDesign()) - Array.from({ length: 9 }, (_, i) => { - const index = i - const number = index + 1 - commands.push({ - id: `project.${number}`, - category: language.t("command.category.project"), - title: `Open Project {number}`, - keybind: `mod+${number}`, - disabled: layout.projects.list().length <= index, - hidden: true, - onSelect: () => navigateToProjectIndex(index), - }) + Array.from({ length: 9 }, (_, i) => { + const index = i + const number = index + 1 + commands.push({ + id: `project.${number}`, + category: language.t("command.category.project"), + title: `Open Project {number}`, + keybind: `mod+${number}`, + disabled: layout.projects.list().length <= index, + hidden: true, + onSelect: () => navigateToProjectIndex(index), }) + }) for (const [id] of availableThemeEntries()) { commands.push({ @@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) { createEffect(() => { document.documentElement.style.setProperty( "--dialog-left-margin", - newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, + `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, ) }) @@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) { ) return ( - - {autoselecting() ?? ""} - -
- }> - {props.children} - -
- {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && } - - -
- } - > -
- {autoselecting() ?? ""} - - - - -
-
-
- +