From 8716c4309a209d50f0b17211e407e317a28cdce3 Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 17 Jun 2026 21:27:16 +0000 Subject: [PATCH 001/259] sync release versions for v1.17.8 --- 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 9bd91760b..4e4aa7a70 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.7", + "version": "1.17.8", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -86,7 +86,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.7", + "version": "1.17.8", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -111,7 +111,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "bin": { "opencode": "./bin/opencode", }, @@ -331,7 +331,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -399,7 +399,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.7", + "version": "1.17.8", "dependencies": { "effect": "catalog:", }, @@ -411,7 +411,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.7", + "version": "1.17.8", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -442,7 +442,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "dependencies": { "@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node-shared": "4.0.0-beta.74", @@ -477,7 +477,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.7", + "version": "1.17.8", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -495,7 +495,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.7", + "version": "1.17.8", "bin": { "opencode": "./bin/opencode", }, @@ -623,7 +623,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.7", + "version": "1.17.8", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -661,7 +661,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.7", + "version": "1.17.8", "dependencies": { "cross-spawn": "catalog:", }, @@ -676,7 +676,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.7", + "version": "1.17.8", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -690,7 +690,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "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.7", + "version": "1.17.8", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -822,7 +822,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.7", + "version": "1.17.8", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -871,7 +871,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.7", + "version": "1.17.8", "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 b9cb6cd80..0b46ec028 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.7", + "version": "1.17.8", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index b5a8076ce..4d2aba066 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.7", + "version": "1.17.8", "type": "module", "license": "MIT", "bin": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 6e34ba3eb..e7840b478 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.7", + "version": "1.17.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index c0639dc94..b5e95fa94 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.7", + "version": "1.17.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 59555f9f5..c215d9da4 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.7", + "version": "1.17.8", "$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 008f328f4..6adb5489d 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.7", + "version": "1.17.8", "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 06bd1f015..2b6d53eac 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.7", + "version": "1.17.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index bafccf4f5..26449b516 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.7", + "version": "1.17.8", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1f52efb86..7933b3982 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.17.7", + "version": "1.17.8", "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 fa841b7a0..033ccb4f0 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.7", + "version": "1.17.8", "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 46873f060..52860ade5 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.7", + "version": "1.17.8", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index ba4199f08..cf775b901 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.17.7", + "version": "1.17.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 07a06858d..5a9259f61 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.17.7", + "version": "1.17.8", "$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 f6a64f9e6..29bda69ed 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.7", + "version": "1.17.8", "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 f2b62e58f..205a27c32 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.7", + "version": "1.17.8", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 35a8beedb..fe6e8a8ff 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.7", + "version": "1.17.8", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 126c68e68..b3c4f4013 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.7", + "version": "1.17.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 785ec1f52..a0dc88f4a 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.7", + "version": "1.17.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 1263016e6..9bc7f4467 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.7", + "version": "1.17.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 3e0846d63..d04cac524 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.7", + "version": "1.17.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index bf96d3256..8317c5bc5 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.7", + "version": "1.17.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 631a845a0..2dcfd5465 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.7", + "version": "1.17.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 8b1b38363..a53b24236 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.7", + "version": "1.17.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 4ac7d0f56..be0ddbfe4 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.7", + "version": "1.17.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 7e665e294..a2485f475 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.17.7", + "version": "1.17.8", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index de2a03900..756726d15 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.7", + "version": "1.17.8", "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 f3efe09ef..37c940a31 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.7", + "version": "1.17.8", "publisher": "sst-dev", "repository": { "type": "git", 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 002/259] 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 ff7f867ab..9c744db89 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 003/259] 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 18aeedd78..e34c3e750 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 83cfd7046..c8b5eb211 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 004/259] 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 378e0b721..52c714a5a 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 57d6464d9..caed54400 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 3860d742d..e8fdadd8e 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 0b72e620a..7c1c9108d 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 093c1894a..e2add116e 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 4882c1392..d6c6a40eb 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 1a1eb0fcc..4d983ee99 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 1d3bdd575..ed18b7acd 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 005/259] 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 e8fdadd8e..b1f7bd8b7 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 48350fe13..b0cf1678c 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 d6c6a40eb..03db8784d 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 006/259] 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 b969043ab..c3e66c048 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 cd7e43dfc..8614a03d0 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 18f9545d0..1c4cc7cc0 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 6ee3b46e2..7f528015a 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 007/259] 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 8614a03d0..92f99fe7d 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 008/259] 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 765e960c8..2e56066e7 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 000000000..e69c91a98 --- /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 000000000..afa3108d5 --- /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 000000000..b9f8ea434 --- /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 000000000..343526e25 --- /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 000000000..d4793daee --- /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 000000000..9097c11f1 --- /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 000000000..741084751 --- /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 000000000..862e080f1 --- /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 000000000..2e80d7038 --- /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 000000000..e315c2ad4 --- /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 000000000..14f9d2d00 --- /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 000000000..6353416d5 --- /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 000000000..64d79283f --- /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 000000000..e1689498c --- /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 000000000..a3cd698cd --- /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 000000000..e5c353e4c --- /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 000000000..dc7e17307 --- /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 000000000..456020ff3 --- /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 000000000..5d20c03a0 --- /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 000000000..dd771b7d5 --- /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 000000000..f8fca1adb --- /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 000000000..c0215c5cb --- /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 c4ef9f6cc..cf0c95243 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 0b46ec028..b572e7308 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 d9648a88b..f68652363 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 009/259] 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 7c89ac8e3..620fdc8b9 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 1c1cbdf3c..6932dbfd5 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 010/259] 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 b3f85c813..b616df6e5 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 08828018a..5cd97f78e 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 011/259] 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 ec3f8bea5..1305a965c 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 012/259] 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 3023ae323..d420e52c6 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 013/259] 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 4d983ee99..f36ddd9da 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 014/259] 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 92ff8fece..f78cc53e7 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 015/259] 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 42dcaef54..80412ca9d 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 23483dda2..46657331a 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 016/259] 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 92f99fe7d..a298a7cbb 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 f6c7f7bef..05569c1b8 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 7f528015a..cdef00b94 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 000000000..3b9784b0d --- /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 a658a4d51..d5bfa314b 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 c58195c77..99c7bcc4a 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 9a74daf95..a7e4c0037 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 0d40a51c5..912ee6290 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 72f9c874e..5a2b25fa4 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 df0a317d6..86e5ded7d 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 017/259] 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 a298a7cbb..6869764f3 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 05569c1b8..01b561c52 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 cdef00b94..862120d03 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 018/259] 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 99c7bcc4a..b75e08a34 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 912ee6290..5cdee912b 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 356f7403a..553c91b9a 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 019/259] 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 | 92 +++++++++---------- packages/opencode/src/session/prompt.ts | 4 +- 5 files changed, 76 insertions(+), 83 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 67a110413..4060cc6b0 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 02a1eb3fe..233a4aa4d 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 3aefa7377..040584ab1 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 af17de175..c3089da0d 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,75 +3189,72 @@ 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" }), - ]) + 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" }), + ], + ] - const failure = yield* session.resume(sessionID).pipe(Effect.flip) + yield* session.resume(sessionID) - expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 }) - expect(requests).toHaveLength(25) + expect(requests).toHaveLength(26) 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", () => + 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 = [ - ...Array.from({ length: 24 }, (_, index) => [ + [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }), + 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.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + 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(25) + 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 b616df6e5..299a0b6b1 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 020/259] 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 299a0b6b1..dad796c99 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 021/259] 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 f78cc53e7..e459380fa 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 022/259] 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 f36ddd9da..e3758374c 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 023/259] 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 4e4aa7a70..a0b3d9b31 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 c0dc905aa..49507128d 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 4de8176e4..d364d6f34 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 9965ff930..769941fd2 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 0239eecad..fa2a3cb54 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 0041c3353..b7ef91633 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 ca626111a..90995b198 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 f3398e839..48ad74c18 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 44418d74c..8367ad68e 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 5a8a94f33..b68078b3f 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 2d15ee8d0..685cf5c58 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 2ba5ef0d7..fa864e925 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 c3089da0d..6ff969fb6 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 675aabcb8..80397cd6e 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 29bda69ed..c9fd3434c 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 35546ca30..072e4e838 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 a685f07d5..3e4601352 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 222296ff1..8c008435a 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 5c18bc3ca..20f937982 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 76aab6ef6..0fdd6f0c7 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 0949ec1be..08d8a53d9 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 63ad8d0d7..d7c85f2ee 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 a2be44ebb..54037002f 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 d265f82bf..9edb717bc 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 62972e30f..763742d9c 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 024/259] 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 a0b3d9b31..e38e531d9 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 b572e7308..e4906b9ea 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 4d2aba066..96283c826 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 e7840b478..e706bd6b0 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 b5e95fa94..fa401b65c 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 c215d9da4..7db84f475 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 6adb5489d..deb27b5e2 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 2b6d53eac..e7ca9810a 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 26449b516..7e12e5bac 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 7933b3982..d3bd09bc0 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 033ccb4f0..8fe9c58f5 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 52860ade5..3f8f18a9a 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 cf775b901..69534b07f 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 5a9259f61..c4d35031c 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 c9fd3434c..7c70a18f2 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 205a27c32..e1a691c0e 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 fe6e8a8ff..139217e34 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 b3c4f4013..d046e4e9f 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 a0dc88f4a..37b688907 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 9bc7f4467..18cd2ab1f 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 d04cac524..c4ba07b23 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 8317c5bc5..d7c6660d7 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 2dcfd5465..c54cb7434 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 a53b24236..c10273a3a 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 be0ddbfe4..aa6b6c70f 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 a2485f475..489396ad4 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 756726d15..3d77162aa 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 37c940a31..ead6f4588 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 025/259] 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 b7231c92a..865023380 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 026/259] 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 e80b8fa90..c786513c6 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 027/259] 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 f5edf1af2..e53a6c1f1 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 028/259] 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 1590e0890..813fe49f3 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 c0b65b5e5..4706235f3 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 029/259] 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 6fae556fc..f2916173d 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 030/259] 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 18e6cf7ac..2c326bb6f 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 6869764f3..c9b7c33ee 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 031/259] 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 2c326bb6f..22342a0ee 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 c54cb7434..38379bc2e 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 54037002f..a2be44ebb 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 9edb717bc..d265f82bf 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 000000000..af610cfb7 --- /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 032/259] 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 af610cfb7..d3a7cc6e6 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 033/259] 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 22342a0ee..e000771c4 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 38379bc2e..76c022a49 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 d3a7cc6e6..cd355083a 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 034/259] 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 cd355083a..078647628 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 035/259] 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 e000771c4..0ceaee3d4 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 036/259] 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 0ceaee3d4..18e6cf7ac 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 037/259] 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 c9b7c33ee..6869764f3 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 038/259] 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 6869764f3..f865690df 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 1c4cc7cc0..e1af50fa4 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 fe7abc9a7..2a07bfc4e 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 862120d03..2439369b6 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 000000000..8267f41cc --- /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 039/259] 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 f865690df..838fa6925 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 e1af50fa4..fc4482639 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 2a07bfc4e..b648adb20 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 2439369b6..90ba9ce9f 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 040/259] 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 838fa6925..a068ed005 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 fc4482639..9d71f7b64 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 b648adb20..81e60f82e 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 90ba9ce9f..1d46d43a1 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 8267f41cc..15beb678b 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 041/259] 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 842764011..83a242d6b 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 042/259] 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 81e60f82e..eac4abb89 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 15beb678b..0fd2da5fd 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 83a242d6b..c8c4d3e13 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 043/259] 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 c8c4d3e13..0a152af29 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 044/259] 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 | 118 ++-- packages/core/src/config/plugin/command.ts | 65 +- packages/core/src/config/plugin/provider.ts | 203 +++--- 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, 3740 insertions(+), 1943 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 e38e531d9..657424dfe 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 7e12e5bac..2aec5ecc7 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 fabf7477d..18e9e59c0 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 4156db5c3..d32f93668 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 f6b8210be..622702e99 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 36534b0d3..ffc268a0e 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* 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)) + } - 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) { + draft.remove(agentID) + continue + } - 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 = 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) + }) } - - 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) - }) } - } - }) + }), + ) }), }) diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index 7e71f306e..a88c60559 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 47a3712e3..0171fee37 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 }) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) + } } } - } - }) + }), + ) - 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) - } - }) - const providerApi = catalog.provider.get(providerID)?.provider.api - const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined + 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, 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], + 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: {}, + 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) } - model.variants.push(existing) + ModelRequest.assign(existing, { + headers: variant.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), + }) } - 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 } - }) + 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 22c766499..f511736e1 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 30b7a8827..9f6a99d8b 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")) }), + ) + } + 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), + ), + }), + ) } - 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)), - }), - ) - } - }) + }), + ) }), }) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 90995b198..4bd27ffd3 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 f9f4f5693..5de1f9803 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 3b0beece5..8a06229e0 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 48ad74c18..3ad8beb0a 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 2d806dbd8..9fde8ef8e 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 aaef65d32..c826ba513 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 e8a8d8bc9..735ddd310 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 cc7b0c247..3acd94a21 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 66386a212..121bc0e6c 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 000000000..833671c0a --- /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 a212d013a..34f466850 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 fa5c0a91c..a75d0c0d0 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 9c7fd6566..fe7bc1036 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 9bd69fe03..7c36d6dd9 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 173fd3662..9115dcefe 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 f87194368..f82f3eacc 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 ba7856b63..d6ba76db6 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 10f3f5200..3904ee5b8 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 991c370d1..df9f64685 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 bbd42f6e2..2f62029a5 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 e5abc7009..4ab7c738d 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 5b08ad9ef..6e8f91861 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 1fc7c0c79..6adc366c0 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 9de090a95..70af07164 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 a7168d59a..e3d429504 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 47e29c6b5..19b240b70 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 f2052afd1..6a6e14ae6 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 e293a66da..f57322a90 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 613f589ba..5c9802065 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 e7f0decb7..a799c2b45 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 837fce2c0..25f695d95 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 76c337370..de2da085f 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 d58bd784f..07fb2fec9 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 56e71f822..1414d5a1e 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 bc56a11b5..81c4911d9 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 2415ab7c1..c9e1873de 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 47c8b7eaa..b3675961b 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 0971f3518..48e5e73aa 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 b1870f266..10eb849ba 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 8a3b95024..2d2bc4dc9 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 a7e0bdf5a..45f117158 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 4e9d53e47..5fc10e867 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 a4f6a0ea0..497561e00 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 620fdc8b9..1dec8ba35 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 3f5424a47..f12fd2c06 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 66eb160eb..5ed46d76e 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 3d93a8999..d4e617ebf 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 259c8aff5..158fb4fab 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 7f1ae58d2..1c540e0e9 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. - */ - 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. + * Registers and applies a scoped transform. Closing the owning Scope removes + * the transform and rebuilds the materialized state. */ - 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 024c2006d..5309e4b4b 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 9f46eca4e..f8b8d4eb2 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) => - editor.update(id, (info) => { - info.description = "Old description" - info.hidden = true - }), - ) - yield* transform((editor) => + let description = "Old description" + let hidden = true + yield* agent.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 77a18e79a..cc1051bc2 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 f2175743e..8c2c5843f 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 79e872f74..ea553671b 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 da3bb749b..bc84d9cdb 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 a2ecc9954..1a6ba447a 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 52b9c0bb6..2f86714bb 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 ca4362c60..ac9cd33e8 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 69dba2ae0..9e75bbb64 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 c149116cd..349d9ab7e 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 ebe06400e..2120a9f51 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 d89d53114..69b63683c 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 099e18251..d9d68e98b 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 000000000..17ebd7bda --- /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 236985dac..6b3e153c3 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 e2fbb8061..017f60fff 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 e1ae5bd67..aadefcb5c 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 85881c3e8..9b496817e 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 3101052cf..6d9139c73 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 c4bdd806c..baa6d4f73 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 aa192274d..5bcb9f7a0 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 72ad5da33..2332a3ca2 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 208ab8710..8e27781d0 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 a646c3eb6..c653f65a0 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 43db117a9..7e2b6322f 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 2b0be314b..f3b0bf898 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 8ee69b7dd..6627d185a 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 f16b177e6..bbc41646a 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 dab52a1f7..b4277d140 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 bdb602948..57c90e814 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 cb23cc452..bebfa1dc8 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 9880ff3ae..c1fab4201 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 c6db66b1c..71eb1eeab 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 c15928435..1d04d7505 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 33da03c03..d54bf3134 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 39a643e34..456880c19 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 b442d4f4d..ea3b3a670 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 e4f781e54..c5c986f62 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 e8bf1f757..7e695c89c 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 d30b585b9..7a50bbf14 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 01cabf358..5fb1b3b36 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 fe8ccb623..83566d416 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 444badd85..35498d5e9 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 565b9280a..51103167b 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 ff5ec4ba4..5de7ae065 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 3457c2ac8..19757e126 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 ff4a922ab..148a30ee4 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 fe0e599ff..027f81d36 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 e505f8538..4ac5cf34f 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 101d65261..4bfdc7b0e 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 63d028e4e..9fefec820 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 dfa8a202a..a5a94b9d2 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 e1f5e32b7..a03ec77f0 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 6ff969fb6..0cc275085 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 d0e01d067..46ee13d12 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 cb795f856..70d522c3f 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 8c08454c4..3a54be6c0 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 74288529d..67a3e29cc 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 d046e4e9f..16a0a6b5e 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 000000000..71fa07bd7 --- /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 000000000..4fbf469d8 --- /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 000000000..11a8c6c20 --- /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 000000000..579de8249 --- /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 000000000..1d44717ae --- /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 000000000..fcb90d196 --- /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 000000000..e6ea7cf0c --- /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 000000000..d242b2a69 --- /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 000000000..707f744b3 --- /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 000000000..46c457451 --- /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 000000000..2acb08b57 --- /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 000000000..bc546a3b1 --- /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 000000000..4cb96c32d --- /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 000000000..f9045cc32 --- /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 000000000..09c919ad6 --- /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 000000000..389674cff --- /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 000000000..05aa0c4b6 --- /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 000000000..d25a71f0d --- /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 37b688907..78643b6a1 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 81e5e93ac..8b1c9959e 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 045/259] 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 f12fd2c06..63ab19e71 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 1a6ba447a..054c6871d 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 17ebd7bda..eb11dc30d 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 9b496817e..d31574d0f 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 bbc41646a..d23672f6f 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 1d04d7505..d30286bc0 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 7a50bbf14..d41a856ce 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 5fb1b3b36..9fe4be973 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 83566d416..49b5875b5 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 027f81d36..c958d139e 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 67a3e29cc..02ad579ac 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 e2add116e..1c4fd8f5d 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 b0cf1678c..0b4327640 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 046/259] 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 4c36f4110..aeeccc42f 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 047/259] 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 a2be44ebb..54037002f 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 d265f82bf..9edb717bc 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 048/259] 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 865023380..ef9387b86 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 049/259] 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 7a33eedc4..b483b280d 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 6c61aff3b..2610cec00 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 d5163cf88..be314e1f9 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 3472cc114..5eaf03716 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 041c62998..f8bc2b0e6 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 caf63de78..b8945494f 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 34bb72968..181ba9807 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 cd8ba6925..c28d902ac 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 3551ec52f..471e86ff9 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 f84f60f30..df9ac731b 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 d663cb715..5b6cc1ce1 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 3d4a858cb..f2d18cfa2 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 0cc275085..393f5526a 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 14a8053f9..4cef7311d 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 87556a1ad..3e1315486 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 050/259] 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 b483b280d..32aaeae69 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 be314e1f9..b5a3bf486 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 b8945494f..bffe4e74c 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 c28d902ac..fb5e195e2 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 5b6cc1ce1..b2aec228e 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 051/259] 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 64f02d813..22ec57b94 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 605a1e17d..1d9553c77 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 052/259] 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 000000000..7cb534a44 --- /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 1e209e500..2ce0868d9 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 cea19c17e..14c10377e 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 b2541e3e2..5a856ffaf 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 dc38a9c35..9715dd5c7 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 053/259] 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 54037002f..50c356afa 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 9edb717bc..2d55ee7f8 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 a7e4c0037..3fc97aba3 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 763742d9c..eda662d98 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 054/259] 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 c27bdae6d..ef0c4df17 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)) + } + if (!done) { + const tail = yield* decodeUtf8(resource, decoder) + if (!discard) pending += tail + if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) } - 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 (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 22ec57b94..2635e4653 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 000000000..2bc175416 --- /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 1d9553c77..c85b7e359 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 055/259] 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 233a4aa4d..5d84e985a 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 5390a26e3..b412edba0 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 ae36f205b..f0ce7eef7 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 708fd9e7f..cc515742d 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 393f5526a..eb5ccb277 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 056/259] 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 a068ed005..0632dd236 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 057/259] 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 0632dd236..223218fcb 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 058/259] 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 fa675f7a7..e962a6e7c 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 059/259] 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 b5a3bf486..90736fb65 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 471e86ff9..96c7c9bd2 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 b1f7bd8b7..5febf9cb2 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 7c1c9108d..7bf19806e 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 1c4fd8f5d..d2c9e2989 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 a208de82f..ac4418d39 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 66383cfbf..1fe860e52 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 060/259] 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 90736fb65..7454e0fa7 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 0b4327640..04165386c 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 061/259] 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 | 97 ++++--- .../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, 2865 insertions(+), 1600 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 000000000..786a37744 --- /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 d32f93668..ed982cb6d 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 937ec4a51..b01bb1d1f 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 a849092ea..3afd7284a 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 5c044ec60..5190e5838 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 3257fe884..7f6ae60ae 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 0f123f5b9..c019b8034 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 4bd27ffd3..f9081525b 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 d4e617ebf..787c62c19 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 cc1051bc2..bb4b256f8 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 054c6871d..19311363e 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 c03859854..6c7f08e11 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 fb5e195e2..2001de4ef 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 10f61d8a9..31371b0ac 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 cdc8344de..77d0a9e33 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 00b3ffbd1..40d8ed9dc 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 ac9cd33e8..c95dbdf38 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 9e75bbb64..21acc40ee 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 5f7fbb16d..84efa4cba 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 2120a9f51..0f07ed547 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 000000000..3faa65a65 --- /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 6b3e153c3..c872b6fe6 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 017f60fff..5fb8b16bf 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 aadefcb5c..7b3cea5c1 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 d31574d0f..389fa8c6f 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 6d9139c73..222e25e9b 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 baa6d4f73..10c2a005d 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 5bcb9f7a0..5501ad39e 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 2332a3ca2..34e6261d3 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 8e27781d0..f6da837d8 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 c653f65a0..f0d09b841 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 7e2b6322f..db7dd1042 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 f3b0bf898..150c9ea84 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 6627d185a..3bd6d2496 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 d23672f6f..f7ca619ea 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 b4277d140..1940bba93 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 57c90e814..fe9b0b0d9 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 bebfa1dc8..f5f62f8df 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 c1fab4201..1197957f5 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 71eb1eeab..dbc97205b 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 d30286bc0..000000000 --- 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 d54bf3134..5e7a7c2d2 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(kilo.id, (draft) => { - draft.api = kilo.api - draft.request = kilo.request + 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(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(kilo.id, (draft) => { - draft.api = kilo.api - }) - const custom = provider("custom-kilo", { - api: { type: "aisdk", package: "kilo" }, + 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(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 456880c19..0fc22c523 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" -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) }), - ), - }) - }) +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", () => { 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 ea3b3a670..f09e0e62c 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 c5c986f62..ee16e5a2b 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 7e695c89c..c0601c2ba 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 d41a856ce..a9911d2cc 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 9fe4be973..750b71463 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 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) })), +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 } }] + 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), + }) + catalog.provider.update(provider.id, (draft) => { + draft.request = provider.request }) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + 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 49b5875b5..5761827c4 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 35498d5e9..eeb00093e 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 51103167b..a6fe38718 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 5de7ae065..c376a6947 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 19757e126..b780124a6 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 148a30ee4..639543af5 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 c958d139e..b3cb5f289 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 4ac5cf34f..a978381de 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(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( + yield* addPlugin() + + 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 4bfdc7b0e..3313cd048 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 000000000..8a7fd8ca7 --- /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 47f2176c3..8c01e92c5 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 c1d2d8801..491c2e260 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 45fba0d18..7f0e9389a 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 57bf39966..3ad95456f 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 96c7c9bd2..6fd80c60d 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 df9ac731b..a0894d07e 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 b2aec228e..c84a3ab30 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 a03ec77f0..50e60a361 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 e8da56a3a..91d7a2447 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 eb5ccb277..862bb56d3 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 d1d656af3..ff10c4050 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 09cc159a2..85dcb604a 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 57a354fc7..d8f96a580 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 2bc175416..c897786e8 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 c85b7e359..fcbec061b 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 c8d179901..38ba1900a 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 de5c7c264..a81b32d37 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 220cd96e5..c9255902c 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 062/259] 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 5190e5838..5c044ec60 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 f9081525b..03192921b 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 6c7f08e11..8c1901acd 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 c95dbdf38..f4a1ccd83 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 7b3cea5c1..1a2512485 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 389fa8c6f..ba3a33915 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 222e25e9b..2c1c7ec87 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 34e6261d3..31ce4448f 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 3bd6d2496..619e184d8 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 f7ca619ea..b8f615f93 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 a6fe38718..6892aaf6a 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 c376a6947..ca839fff5 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 b3cb5f289..5abc737dd 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 7f0e9389a..8c52f4a98 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 a9dd3006315b8776e4de88ec638e8e68d7270f58 Mon Sep 17 00:00:00 2001 From: G626 <103378730+Krablante@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:33:38 +0000 Subject: [PATCH 063/259] docs: document project indexing guard --- README.md | 2 ++ docs/opencodez.md | 31 +++++++++++++++++++++++++++++-- packages/core/src/flag/flag.ts | 1 + packages/core/src/project.ts | 1 + 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c30c5e19b..dcaa88446 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ OpenCodez keeps the normal OpenCode shape, but adds a few practical controls: - The TUI shows the concrete active System prompt id and Tone preset while you work. - `/pruning` lets you view and change session-local pruning settings. - Tool calls stay readable while tool result and reasoning payloads can be replaced with deterministic placeholders. +- Non-git projects stay scoped to the selected directory, explicit filesystem + roots clamp to `$HOME`, and FFF indexing is disabled by default. OpenCodez is meant to be a small fork, not a full rebrand. Upstream internals, docs, workflows, integrations, and package surfaces should stay as close to OpenCode as practical unless a fork-specific change is genuinely needed. diff --git a/docs/opencodez.md b/docs/opencodez.md index a87c11803..7be7d98ed 100644 --- a/docs/opencodez.md +++ b/docs/opencodez.md @@ -4,8 +4,8 @@ This page is the public source of truth for OpenCodez-specific behavior. OpenCodez is a small, maintainable fork of upstream OpenCode. It keeps the normal OpenCode product shape, but adds explicit prompt control, bundled -Codex-style prompt presets, session-local prompt selection, and safer context -pruning. +Codex-style prompt presets, session-local prompt selection, safer context +pruning, and bounded project discovery defaults. The goal is not to turn OpenCodez into a separate product with renamed internals. OpenCodez should stay easy to compare with upstream OpenCode, easy to rebase, @@ -21,6 +21,7 @@ This page explains only what OpenCodez adds or changes: - `/system`, `/tone`, `/template`, `/prompts`, and `/pruning`; - model-aware prompt defaults; - session persistence for manual prompt choices; +- project discovery and indexing safety defaults; - OpenCodez config roots, install, and update behavior; - maintenance rules that keep the fork small. @@ -119,6 +120,23 @@ If `binary_version` is omitted, the workflow uses the part of OpenCodez-specific while keeping the embedded binary version simple for `opencodez update`. +## Project Discovery and Indexing Safety + +OpenCodez keeps global projects scoped to the directory the user selected. +When project resolution starts from a directory outside a git checkout, +OpenCodez returns the global project for that directory instead of widening the +project directory to the filesystem root. + +If the selected directory is the filesystem root itself, OpenCodez clamps the +global project directory to the user's home directory. This prevents web and +other project-aware surfaces from treating `/` as the project root and walking +the whole host filesystem. + +OpenCodez also disables the native FFF file finder integration by default via +`OPENCODE_DISABLE_FFF`. Users can still opt into upstream-style FFF behavior by +setting `OPENCODE_DISABLE_FFF=false` or `OPENCODE_DISABLE_FFF=0`, but the fork +default favors bounded indexing over root-wide discovery. + ## Prompt Library OpenCodez exposes prompt files as a normal user-facing library. @@ -578,6 +596,15 @@ packages/core/src/opencodez/ packages/core/src/v1/config/opencodez.ts ``` +Project discovery and bounded indexing defaults live in upstream-shaped core +paths, not in an isolated OpenCodez adapter: + +```text +packages/core/src/project.ts +packages/core/src/flag/flag.ts +packages/core/test/project.test.ts +``` + Upstream System prompt integration still belongs to the opencode server package: ```text diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index dbb820b82..004b48429 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -31,6 +31,7 @@ export const Flag = { OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], + // OpenCodez: default FFF off to avoid broad indexing. OPENCODE_DISABLE_FFF: fff === undefined ? true : truthy("OPENCODE_DISABLE_FFF"), // Experimental diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 4660a62c3..0037cdcaa 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -112,6 +112,7 @@ export const layer = Layer.effect( const repo = yield* git.find(input) if (!repo) { const filesystemRoot = AbsolutePath.make(path.parse(input).root) + // OpenCodez: keep non-git projects bounded. const directory = input === filesystemRoot ? AbsolutePath.make(os.homedir()) : input return { id: ID.global, directory, vcs: undefined } } From a3801caa46a1cddd4f360266d80b3a66123553be Mon Sep 17 00:00:00 2001 From: G626 <103378730+Krablante@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:10:32 +0000 Subject: [PATCH 064/259] feat: show progress during opencodez update --- README.md | 5 + docs/opencodez.md | 24 +++ .../opencode/src/cli/cmd/opencodez-update.ts | 73 ++++++++- packages/opencode/src/opencodez/update.ts | 74 ++++++++- .../opencode/test/opencodez/update.test.ts | 141 ++++++++++++++++-- 5 files changed, 301 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index dcaa88446..8dc15fcd4 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ OpenCodez keeps the normal OpenCode shape, but adds a few practical controls: - Tool calls stay readable while tool result and reasoning payloads can be replaced with deterministic placeholders. - Non-git projects stay scoped to the selected directory, explicit filesystem roots clamp to `$HOME`, and FFF indexing is disabled by default. +- `opencodez update` prints GitHub release, download progress, and install + stages instead of staying silent during large asset downloads. OpenCodez is meant to be a small fork, not a full rebrand. Upstream internals, docs, workflows, integrations, and package surfaces should stay as close to OpenCode as practical unless a fork-specific change is genuinely needed. @@ -88,6 +90,9 @@ opencodez update --check ``` The update path is intentionally simple: GitHub Releases are the source of truth, the installer downloads the right release artifact for the current OS and architecture, and `opencodez update` uses the same release channel from inside the app. +During the download, `opencodez update` prints progress to stderr. When GitHub +provides `Content-Length`, progress includes total MB and percent; otherwise it +prints downloaded MB only. If the installed binary is newer than the latest published release, `opencodez update` treats it as current instead of downgrading it. ## Run From Source diff --git a/docs/opencodez.md b/docs/opencodez.md index 7be7d98ed..23b426b35 100644 --- a/docs/opencodez.md +++ b/docs/opencodez.md @@ -22,6 +22,7 @@ This page explains only what OpenCodez adds or changes: - model-aware prompt defaults; - session persistence for manual prompt choices; - project discovery and indexing safety defaults; +- visible progress for release downloads in `opencodez update`; - OpenCodez config roots, install, and update behavior; - maintenance rules that keep the fork small. @@ -120,6 +121,21 @@ If `binary_version` is omitted, the workflow uses the part of OpenCodez-specific while keeping the embedded binary version simple for `opencodez update`. +## Update Download Progress + +`opencodez update` emits visible progress while it checks GitHub Releases, +downloads the selected release asset, and begins installation. This avoids the +silent wait that can happen on weak connections when a large asset is read into +memory. + +During asset downloads, OpenCodez reads the response body as a stream. If GitHub +provides `Content-Length`, the CLI prints downloaded MB, total MB, and percent. +If the header is absent, the CLI prints downloaded MB only. + +Progress is emitted as typed update events from `OpenCodezUpdate.run()` and +rendered by the CLI command. The update helper stays UI-agnostic so future UIs +can reuse the same stages without depending on terminal output. + ## Project Discovery and Indexing Safety OpenCodez keeps global projects scoped to the directory the user selected. @@ -635,6 +651,14 @@ packages/opencode/src/server/routes/instance/httpapi/handlers/opencodez.ts packages/sdk/js/src/v2/gen/ ``` +Release update progress: + +```text +packages/opencode/src/opencodez/update.ts +packages/opencode/src/cli/cmd/opencodez-update.ts +packages/opencode/test/opencodez/update.test.ts +``` + Focused tests: ```text diff --git a/packages/opencode/src/cli/cmd/opencodez-update.ts b/packages/opencode/src/cli/cmd/opencodez-update.ts index 2d72fcec5..b60a6ed43 100644 --- a/packages/opencode/src/cli/cmd/opencodez-update.ts +++ b/packages/opencode/src/cli/cmd/opencodez-update.ts @@ -11,15 +11,84 @@ export const OpenCodezUpdateCommand: CommandModule, { ch type: "boolean", describe: "check for an available OpenCodez release without installing it", default: false, - }), + }), handler: async (args: { check?: boolean }) => { const check = args.check === true - const result = await OpenCodezUpdate.run({ check, current: InstallationVersion }) + const progress = createProgressReporter() + const result = await OpenCodezUpdate.run({ + check, + current: InstallationVersion, + onEvent: (event) => progress.onEvent(event), + }) + progress.finish() UI.println(result.message) if (result.status === "error" || (!check && result.status === "unavailable")) process.exit(1) }, } +function createProgressReporter() { + let progressLine = false + let lastProgressAt = 0 + + function finish() { + if (!progressLine) return + UI.print("\n") + progressLine = false + } + + function writeProgress(line: string, force = false) { + const now = Date.now() + if (!force && now - lastProgressAt < 100) return + lastProgressAt = now + if (process.stderr.isTTY) { + UI.print(`\r${line}\x1b[K`) + progressLine = true + return + } + UI.println(line) + } + + return { + finish, + onEvent(event: OpenCodezUpdate.UpdateEvent) { + switch (event.type) { + case "checking": + finish() + UI.println("Checking GitHub Releases...") + break + case "latest": + finish() + UI.println(`Latest release: ${event.tag}`) + break + case "download-start": + finish() + writeProgress(renderDownloadProgress(event.asset, 0, event.totalBytes), true) + break + case "download-progress": + writeProgress( + renderDownloadProgress(event.asset, event.downloadedBytes, event.totalBytes), + event.totalBytes !== undefined && event.downloadedBytes >= event.totalBytes, + ) + break + case "installing": + finish() + UI.println(`Installing ${event.asset}...`) + break + } + }, + } +} + +function renderDownloadProgress(asset: string, downloadedBytes: number, totalBytes: number | undefined) { + if (totalBytes === undefined) return `Downloading ${asset}: ${formatMegabytes(downloadedBytes)}` + const percent = totalBytes === 0 ? 100 : Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)) + return `Downloading ${asset}: ${formatMegabytes(downloadedBytes)} / ${formatMegabytes(totalBytes)} ${percent}%` +} + +function formatMegabytes(bytes: number) { + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + export const OpenCodezUpgradeCommand: CommandModule = { command: "upgrade", describe: false, diff --git a/packages/opencode/src/opencodez/update.ts b/packages/opencode/src/opencodez/update.ts index 74aee20e5..50a48c66b 100644 --- a/packages/opencode/src/opencodez/update.ts +++ b/packages/opencode/src/opencodez/update.ts @@ -25,9 +25,21 @@ export interface Result { message: string } -export async function run(input: { check: boolean; current?: string }): Promise { +export type UpdateEvent = + | { type: "checking" } + | { type: "latest"; tag: string; url: string } + | { type: "download-start"; asset: string; totalBytes?: number } + | { type: "download-progress"; asset: string; downloadedBytes: number; totalBytes?: number } + | { type: "installing"; asset: string; target: string } + +export async function run(input: { + check: boolean + current?: string + onEvent?: (event: UpdateEvent) => void +}): Promise { let release: z.infer try { + emit(input.onEvent, { type: "checking" }) const response = await fetch(RELEASES_API, { headers: { Accept: "application/vnd.github+json", @@ -41,6 +53,7 @@ export async function run(input: { check: boolean; current?: string }): Promise< } } release = Release.parse(await response.json()) + emit(input.onEvent, { type: "latest", tag: release.tag_name, url: release.html_url }) } catch (error) { return { status: "error", @@ -96,7 +109,8 @@ export async function run(input: { check: boolean; current?: string }): Promise< message: `Unable to download ${asset.name}: ${response.status} ${response.statusText}`, } } - const bytes = new Uint8Array(await response.arrayBuffer()) + const bytes = await downloadAsset(response, { asset: asset.name, onEvent: input.onEvent }) + emit(input.onEvent, { type: "installing", asset: asset.name, target }) await installAsset({ name: asset.name, bytes, target }) return { status: "updated", @@ -110,6 +124,62 @@ export async function run(input: { check: boolean; current?: string }): Promise< } } +function emit(onEvent: ((event: UpdateEvent) => void) | undefined, event: UpdateEvent) { + onEvent?.(event) +} + +async function downloadAsset( + response: Response, + input: { asset: string; onEvent?: (event: UpdateEvent) => void }, +): Promise { + const totalBytes = parseContentLength(response.headers.get("content-length")) + emit(input.onEvent, { type: "download-start", asset: input.asset, totalBytes }) + + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()) + emit(input.onEvent, { + type: "download-progress", + asset: input.asset, + downloadedBytes: bytes.byteLength, + totalBytes, + }) + return bytes + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let downloadedBytes = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (!value) continue + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value) + chunks.push(chunk) + downloadedBytes += chunk.byteLength + emit(input.onEvent, { type: "download-progress", asset: input.asset, downloadedBytes, totalBytes }) + } + } finally { + reader.releaseLock() + } + + if (chunks.length === 1) return chunks[0] + const bytes = new Uint8Array(downloadedBytes) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +function parseContentLength(value: string | null) { + if (!value) return undefined + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0) return undefined + return parsed +} + function normalizeVersion(value: string) { return value.replace(/^v/, "") } diff --git a/packages/opencode/test/opencodez/update.test.ts b/packages/opencode/test/opencodez/update.test.ts index 01bd4cdb1..e40bbee83 100644 --- a/packages/opencode/test/opencodez/update.test.ts +++ b/packages/opencode/test/opencodez/update.test.ts @@ -1,11 +1,25 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" import { OpenCodezUpdate } from "../../src/opencodez/update" const originalFetch = globalThis.fetch +const originalUpdateTarget = process.env["OPENCODEZ_UPDATE_TARGET"] -afterEach(() => { +async function withRestoredGlobals(run: () => Promise) { + try { + return await run() + } finally { + restoreGlobals() + } +} + +function restoreGlobals() { ;(globalThis as { fetch: typeof fetch }).fetch = originalFetch -}) + if (originalUpdateTarget === undefined) delete process.env["OPENCODEZ_UPDATE_TARGET"] + else process.env["OPENCODEZ_UPDATE_TARGET"] = originalUpdateTarget +} function mockLatestRelease(tag: string) { ;(globalThis as { fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise }).fetch = async () => @@ -19,24 +33,127 @@ function mockLatestRelease(tag: string) { ) } +function mockReleaseDownload(input: { tag: string; asset: string; chunks: string[]; contentLength?: number }) { + const encoder = new TextEncoder() + let calls = 0 + ;(globalThis as { fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise }).fetch = async () => { + calls++ + if (calls === 1) { + return new Response( + JSON.stringify({ + tag_name: input.tag, + html_url: `https://github.com/Krablante/opencodez/releases/tag/${input.tag}`, + assets: [{ name: input.asset, browser_download_url: "https://example.com/opencodez" }], + }), + { status: 200 }, + ) + } + + const headers = new Headers() + if (input.contentLength !== undefined) headers.set("content-length", String(input.contentLength)) + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of input.chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }), + { status: 200, headers }, + ) + } +} + +async function updateTarget() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencodez-update-test-")) + const target = path.join(dir, "opencodez") + process.env["OPENCODEZ_UPDATE_TARGET"] = target + return target +} + +function localAssetName() { + const platform = process.platform === "win32" ? "windows" : process.platform + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch + return `opencodez-${platform}-${arch}` +} + describe("OpenCodezUpdate", () => { test("does not offer an older GitHub release as an update", async () => { - mockLatestRelease("v1.15.13+opencodez.1") + await withRestoredGlobals(async () => { + mockLatestRelease("v1.15.13+opencodez.1") - const result = await OpenCodezUpdate.run({ check: true, current: "1.17.8" }) + const result = await OpenCodezUpdate.run({ check: true, current: "1.17.8" }) - expect(result.status).toBe("current") - expect(result.message).toContain("installed 1.17.8") + expect(result.status).toBe("current") + expect(result.message).toContain("installed 1.17.8") + }) }) test("offers a newer GitHub release when one is available", async () => { - mockLatestRelease("v1.18.0") + await withRestoredGlobals(async () => { + mockLatestRelease("v1.18.0") + + const result = await OpenCodezUpdate.run({ check: true, current: "1.17.8" }) + + expect(result).toEqual({ + status: "available", + message: "Latest OpenCodez release is v1.18.0: opencodez-linux-x64.tar.gz", + }) + }) + }) + + test("emits download progress while streaming a release asset", async () => { + await withRestoredGlobals(async () => { + const asset = localAssetName() + const target = await updateTarget() + const events: OpenCodezUpdate.UpdateEvent[] = [] + mockReleaseDownload({ tag: "v1.18.0", asset, chunks: ["open", "codez"], contentLength: 9 }) + + const result = await OpenCodezUpdate.run({ + check: false, + current: "1.17.8", + onEvent: (event) => events.push(event), + }) + + expect(result).toEqual({ status: "updated", message: "Updated OpenCodez to v1.18.0." }) + expect(await fs.readFile(target, "utf8")).toBe("opencodez") + expect(events.map((event) => event.type)).toEqual([ + "checking", + "latest", + "download-start", + "download-progress", + "download-progress", + "installing", + ]) + expect(events.filter((event) => event.type === "download-start")).toEqual([ + { type: "download-start", asset, totalBytes: 9 }, + ]) + expect(events.filter((event) => event.type === "download-progress")).toEqual([ + { type: "download-progress", asset, downloadedBytes: 4, totalBytes: 9 }, + { type: "download-progress", asset, downloadedBytes: 9, totalBytes: 9 }, + ]) + }) + }) + + test("emits downloaded bytes when content length is unknown", async () => { + await withRestoredGlobals(async () => { + const asset = localAssetName() + const events: OpenCodezUpdate.UpdateEvent[] = [] + mockReleaseDownload({ tag: "v1.18.0", asset, chunks: ["abc"] }) + await updateTarget() - const result = await OpenCodezUpdate.run({ check: true, current: "1.17.8" }) + const result = await OpenCodezUpdate.run({ + check: false, + current: "1.17.8", + onEvent: (event) => events.push(event), + }) - expect(result).toEqual({ - status: "available", - message: "Latest OpenCodez release is v1.18.0: opencodez-linux-x64.tar.gz", + expect(result.status).toBe("updated") + expect(events.filter((event) => event.type === "download-start")).toEqual([ + { type: "download-start", asset, totalBytes: undefined }, + ]) + expect(events.filter((event) => event.type === "download-progress")).toEqual([ + { type: "download-progress", asset, downloadedBytes: 3, totalBytes: undefined }, + ]) }) }) }) From c7efbe6fc08fa42a5e5a3485a0127867064fba71 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 22 Jun 2026 13:20:43 +0200 Subject: [PATCH 065/259] 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 6f4508cb0..958632776 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 00d2e64b3..b15cfc019 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 d40a4d7f6..fa63156d8 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 066/259] 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 b15cfc019..bd5847e27 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 fa63156d8..9c34e336b 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 067/259] 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 8a365a896..d4ecfed97 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 068/259] 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 | 2021 ++++++++++++++++- 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, 2172 insertions(+), 310 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 32aaeae69..dedafbf03 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 8517da276..2a7266c51 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 5febf9cb2..728810e6e 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 9282db32c..503ecb78e 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 7bf19806e..e6bec85f9 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 d2c9e2989..abb9668f6 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 @@ -4050,130 +4138,1884 @@ export type ProviderV2Info = { headers: { [key: string]: string } - body: { - [key: string]: unknown + body: { + [key: string]: unknown + } + } +} + +export type IntegrationWhen = { + key: string + op: "eq" | "neq" + value: string +} + +export type IntegrationTextPrompt = { + type: "text" + key: string + message: string + placeholder?: string + when?: IntegrationWhen +} + +export type IntegrationSelectPrompt = { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: IntegrationWhen +} + +export type IntegrationOAuthMethod = { + id: string + type: "oauth" + label: string + prompts?: Array +} + +export type IntegrationKeyMethod = { + type: "key" + label?: string +} + +export type IntegrationEnvMethod = { + type: "env" + names: Array +} + +export type ConnectionCredentialInfo = { + type: "credential" + id: string + label: string +} + +export type ConnectionEnvInfo = { + type: "env" + name: string +} + +export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo + +export type IntegrationInfo = { + id: string + name: string + methods: Array + connections: Array +} + +export type IntegrationAttempt = { + attemptID: string + url: string + instructions: string + mode: "auto" | "code" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + +export type PermissionV2Request = { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source +} + +export type PermissionSavedInfo = { + id: string + projectID: string + action: string + resource: string +} + +export type FileSystemEntry = { + path: string + type: "file" | "directory" + mime: string +} + +export type CommandV2Info = { + name: string + template: string + description?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + subtask?: boolean +} + +export type SkillV2Info = { + name: string + description?: string + slash?: boolean + location: string + 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 IntegrationWhen = { - key: string - op: "eq" | "neq" - value: string -} - -export type IntegrationTextPrompt = { - type: "text" - key: string - message: string - placeholder?: string - when?: IntegrationWhen +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 IntegrationSelectPrompt = { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: IntegrationWhen +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 IntegrationOAuthMethod = { +export type V2EventQuestionAsked = { id: string - type: "oauth" - label: string - prompts?: Array + 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 IntegrationKeyMethod = { - type: "key" - label?: string +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 IntegrationEnvMethod = { - type: "env" - names: 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 ConnectionCredentialInfo = { - type: "credential" +export type V2EventSessionCompacted = { id: string - label: string + metadata?: { + [key: string]: unknown + } + type: "session.compacted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } } -export type ConnectionEnvInfo = { - type: "env" - name: 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 ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo - -export type IntegrationInfo = { +export type V2EventWorkspaceReady = { id: string - name: string - methods: Array - connections: Array + metadata?: { + [key: string]: unknown + } + type: "workspace.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + } } -export type IntegrationAttempt = { - attemptID: string - url: string - instructions: string - mode: "auto" | "code" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" +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 PermissionV2Request = { +export type V2EventWorkspaceStatus = { id: string - sessionID: string - action: string - resources: Array - save?: Array metadata?: { [key: string]: unknown } - source?: PermissionV2Source + type: "workspace.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } } -export type PermissionSavedInfo = { +export type V2EventWorktreeReady = { id: string - projectID: string - action: string - resource: string + metadata?: { + [key: string]: unknown + } + type: "worktree.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + branch?: string + } } -export type FileSystemEntry = { - path: string - type: "file" | "directory" - mime: 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 CommandV2Info = { - name: string - template: string - description?: string - agent?: string - model?: { - id: string - providerID: string - variant?: 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 } - subtask?: boolean } -export type SkillV2Info = { - name: string - description?: string - slash?: boolean - location: string - content: string +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 = { @@ -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 83ccdf98f..a7f912547 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 65ec78a7c..8001fb874 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 bea6e001a..184837c54 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 92894620a..0d6ada4d1 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 ed18b7acd..d1cf3c7df 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 069/259] 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 dedafbf03..47f9753b9 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 070/259] 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 47f9753b9..32aaeae69 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 a7f912547..fffa0250d 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 071/259] 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 abb9668f6..b2900e8d6 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 04165386c..d9aee6051 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 184837c54..2b39dd33b 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 072/259] 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 48b555b59..4f383f5f8 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 073/259] 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 aeeccc42f..5776e1bf2 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 074/259] 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 1d46d43a1..8e4b86a00 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 f0d4994bb..346d4da4f 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 075/259] 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 2b39dd33b..05cf4afbe 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 076/259] 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 c826ba513..0a02ddfa7 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 69b63683c..d8fe74336 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 077/259] 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 278cee8a7..6f5aea6a1 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 05af009b8..910787f94 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 958632776..fad09c3a7 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 dad46a7fb..d236fb02c 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 65cd15f1a..90cddffa2 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 20194b95c..141ff6fc5 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 62e1a2d8a..a914922e4 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 68941e976..329874791 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 a672d2aca..25a4c38f9 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 edd92120a..a2626b113 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 f79fd40da..73f87d904 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 078/259] 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 09e25de8c..b74df8deb 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 079/259] 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 c8fa34b50..3a2311c8f 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 4a1fed553..66b353c82 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 080/259] 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 7388705d8..7d3255634 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 2610cec00..212583b55 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 787c62c19..968933a6b 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 21acc40ee..0b3e0c8e5 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 c5f90e92c..fb9397e57 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) }), - }) - - 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 }) + 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") }), + }) - const error = yield* opencode.sessions - .switchModel({ sessionID, model: ref({ variant: "unknown" }) }) - .pipe(Effect.flip) + yield* opencode.sessions.switchModel({ sessionID, model }) - 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 50e60a361..e67ade131 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 081/259] 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 1c12ba641..faf8ce9d1 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 c041a4e01..5f71bfdb6 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 1e915bb3c..fd778414a 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 000000000..1520bac4c --- /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 5c044ec60..ed60fde6c 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 1fb8df92e..18624706a 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 285c1bcd5..fb55ab075 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 bffe4e74c..30af9f2e6 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 4060cc6b0..2210b57b3 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 5d84e985a..756a11928 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 ca3d8e1b5..a9499554b 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 d7126f76e..914243a58 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 862bb56d3..f37a4c357 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"], - ]) - }), - ) - - 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"], + ["Initial context\n\nBuild 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 4356c234a..a9a08b501 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 ed30890fa..43c93b175 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 002139cdd..5d77cbea3 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 082/259] 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 5f71bfdb6..d0eeeebd5 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 083/259] 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 503ecb78e..e178062e7 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 084/259] 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 cdcded9da..7a125824e 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 085/259] 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 5cd97f78e..9c22e0041 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 086/259] 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 | 287 ++---- 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 | 848 ++---------------- .../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, 211 insertions(+), 1378 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 7454e0fa7..5dae69973 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 5eaf03716..97e334617 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 9a99145bf..a08912e95 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 8f1b1763a..7e0e3ca70 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 c579ec15d..000000000 --- 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 cf1eb2ced..2c836dcd0 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 30af9f2e6..50bfad465 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 d52b63e8f..6597842be 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" - -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 +import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "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 }) - return + if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key)))) + return restore(Deferred.await(entry.done)) } - const next = makeEntry({ _tag: "wake", seq }) + const next = makeEntry() active.set(key, next) - start(key, next, next.current) + start(key, next, true) + return restore(Deferred.await(next.done)) }) - 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 + const wake = (key: Key) => + Effect.sync(() => { + const entry = active.get(key) + if (entry !== undefined) { + entry.pendingWake = true + return } - if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure) + + const next = makeEntry() + active.set(key, next) + start(key, next, false) }) - 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 2210b57b3..634075dd9 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 756a11928..ddd2bf4e1 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 3d6cff2e4..000000000 --- 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 c84a3ab30..166b5deed 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 39fb5779d..909ba6487 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,176 +22,55 @@ describe("SessionRunCoordinator", () => { expect(runs).toBe(1) yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) - expect(runs).toBe(1) - }), - ), - ) - - it.effect("starts a drain when woken while idle", () => - Effect.scoped( - Effect.gen(function* () { - const drained = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Deferred.succeed(drained, undefined) }) - - yield* coordinator.wake("session") - yield* Deferred.await(drained) - }), - ), - ) - - 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") + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) expect(runs).toBe(1) }), ), ) - it.effect("does not interrupt a wake newer than the interrupt boundary", () => + 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 interrupted = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( + 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)), - Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), ), }) - yield* coordinator.wake("session", 3) + yield* coordinator.wake("session") yield* Deferred.await(started) - yield* coordinator.interrupt("session", 2) - expect(yield* Deferred.isDone(interrupted)).toBeFalse() + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Effect.yieldNow yield* Deferred.succeed(gate, undefined) - yield* coordinator.awaitIdle("session") - }), - ), - ) + yield* Fiber.join(resumed) - it.effect("preserves a queued wake newer than the interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = 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)) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session", 1) - 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) + expect(forces).toEqual([false]) }), ), ) - it.effect("interrupts only the requested key", () => + it.effect("starts execution when woken while idle", () => 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 drained = yield* Deferred.make() + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Deferred.succeed(drained, undefined) }) - 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") + yield* Deferred.await(drained) }), ), ) - it.effect("suppresses a wake received during interruption cleanup", () => + it.effect("coalesces wakes received during active execution", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() + const firstGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() let runs = 0 const coordinator = yield* SessionRunCoordinator.make({ @@ -199,185 +78,100 @@ describe("SessionRunCoordinator", () => { 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(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(firstGate))) : Deferred.succeed(secondStarted, undefined), ), ), }) - yield* coordinator.wake("session") + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) 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* Effect.all([coordinator.wake("session"), coordinator.wake("session"), coordinator.wake("session")], { + concurrency: "unbounded", + }) + yield* Deferred.succeed(firstGate, undefined) yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") + yield* Fiber.join(resumed) + expect(runs).toBe(2) }), ), ) - it.effect("remembers a wake received after the interrupt boundary during cleanup", () => + it.effect("runs again when woken during the follow-up", () => Effect.scoped( Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() + 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: () => 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), + ? Deferred.await(firstGate) + : run === 2 + ? Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))) + : Deferred.succeed(thirdStarted, undefined), ), ), }) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Effect.yieldNow 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.succeed(firstGate, undefined) yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") + yield* coordinator.wake("session") + yield* Deferred.succeed(secondGate, undefined) + yield* Deferred.await(thirdStarted) + yield* Fiber.join(resumed) - expect(runs).toBe(2) + expect(runs).toBe(3) }), ), ) - it.effect("moves the stop barrier forward for repeated interrupts", () => + it.effect("does nothing when interrupted while idle", () => 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) + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) + yield* coordinator.interrupt("session") }), ), ) - it.effect("interrupts an explicit run queued before the interruption request", () => + it.effect("interrupts active execution and clears its pending wake", () => Effect.scoped( Effect.gen(function* () { - const firstStarted = yield* Deferred.make() + const started = 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.void, - ), + 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* 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) + 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("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", () => + it.effect("runs a wake registered during interruption cleanup", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() @@ -405,467 +199,83 @@ describe("SessionRunCoordinator", () => { 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* coordinator.wake("session") 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", () => + 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() - 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), - ), - ), + 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 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) + const resumed = yield* coordinator.run("session").pipe(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* Effect.all([Fiber.join(interrupt), Fiber.join(resumed)]) 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) + expect(forces).toEqual([false, true]) }), ), ) - 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", () => + it.effect("starts one follow-up when a wake races with failure", () => 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) - - 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", () => - Effect.scoped( - Effect.gen(function* () { - const firstGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const secondGate = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.await(firstGate) - : run === 2 - ? Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))) - : Effect.void, - ), - ), - }) - - const first = 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) - - expect(runs).toBe(3) - }), - ), - ) - - it.effect("starts one successor after a wake races with failure", () => - Effect.scoped( - Effect.gen(function* () { - const gate = 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) - }), - ), - ) - - 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([]) + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) }), ), ) - 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 91d7a2447..65e90cb6d 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 f37a4c357..6e97ab793 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 b2900e8d6..e27957f88 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 d9aee6051..7501fc4b9 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 a9a08b501..f32948c82 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 43c93b175..ea22e6008 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 5d77cbea3..893b1dc2d 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 087/259] 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 6597842be..cfab42300 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 088/259] 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 8e4b86a00..51ca059f8 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 089/259] 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 93c22ea6a..c13a9c439 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 090/259] 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 66b353c82..c5b6003fd 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 9966b92e3..5dbc89f1a 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 717a7e802..b854537fe 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 091/259] 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 fd778414a..19b1b5684 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 000000000..b771a64bb --- /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 97e334617..d4b773d18 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 50bfad465..2c87dfb8d 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 914243a58..835f41931 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 e27957f88..b15ff9347 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 7501fc4b9..0d8dc1aec 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 f32948c82..6d9c0efdd 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 ea22e6008..994675832 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 092/259] 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 e34c3e750..46a0f17fc 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 4355c1881..bd18d4450 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 093/259] ignore: update agents.md (#33446) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 02b1c4cb7..1557a7f56 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 094/259] 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 19b1b5684..e6ea4eaa1 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 000000000..0b5ddd1bf --- /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 32aaeae69..3439a0aef 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 18624706a..f06b69cd5 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 d4b773d18..88ac4aa26 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 f8bc2b0e6..a45a37100 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 2c836dcd0..4ece3c2d1 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 2c87dfb8d..4a0512d47 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 ddd2bf4e1..9caae3849 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 835f41931..637687193 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 6fd80c60d..1ae6c011f 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 a0894d07e..76c3290a7 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 166b5deed..842474396 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 65e90cb6d..331c5bb48 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 6e97ab793..ec932c904 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 b15ff9347..f71b01042 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 0d8dc1aec..0f8f5ca19 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 05cf4afbe..9b2e58907 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 0d6ada4d1..279ba4d06 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 6d9c0efdd..bdd483715 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 994675832..6788a893e 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 095/259] 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 | 145 +++++++++++----------- 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, 143 insertions(+), 121 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1557a7f56..4c6be738d 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 faf8ce9d1..7fe7f3fc8 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 9caae3849..2cd77beb0 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 ec932c904..572c599be 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,39 +2805,55 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("continues past 25 local tool steps when the agent has no step limit", () => + 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.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: "Loop forever" }), resume: false }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), 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.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.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + 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(26) - expect(executions).toHaveLength(25) + 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" } }] }, + ]) }), ) - it.effect("forces a text response on an agent's configured final step", () => + it.effect("resets the configured step allowance when steering input promotes", () => Effect.gen(function* () { yield* setup const agents = yield* AgentV2.Service @@ -2870,41 +2863,45 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + 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-terminal", name: "echo", input: { text: "done" } }), + 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-forbidden", name: "echo", input: { text: "forbidden" } }), + 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() - yield* session.resume(sessionID) + 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(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" } }] }, - ]) + 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"]) }), ) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e6bec85f9..d501e2813 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 0f8f5ca19..14c8b94dd 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 ac4418d39..6a4f4aff6 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 bdd483715..cb90e305e 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 6788a893e..6529ff223 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 893b1dc2d..b774c51c6 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 096/259] 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 | 143 +---- 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 | 112 ++-- .../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 | 606 ++---------------- 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, 3276 insertions(+), 3906 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 18e9e59c0..f27b5c3ef 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 769941fd2..9ea79394f 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 ed982cb6d..ade2d6460 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 622702e99..947ad311e 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 ffc268a0e..48efe7580 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 a88c60559..f9b31f8e4 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 000000000..d81d9f7c8 --- /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 0171fee37..6fb13f1c6 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 f511736e1..82487e4a8 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 9f6a99d8b..eca8b5cca 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 03192921b..1e1e613e3 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 0a02ddfa7..85c51ea7d 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 }) + }), + ) + }) - // 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 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 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 }) - }), - ) + 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)), }), - 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, - }, - }), - ) - } + 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 item of registrations) { - if (item.name !== name) continue - const result = item.callback(event as never) - if (Effect.isEffect(result)) yield* result - } + for (const id of active.keys()) { + if (!desired.has(id)) yield* detach(id) + } - for (const [field, draft] of draftEntries) { - event[field] = finishDraft(draft) - } + for (const plugin of draft.list()) yield* attach(plugin, host) + }).pipe(Effect.withSpan("Plugin.reconcile")), + ), + }) - 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 } + yield* Effect.addFinalizer((exit) => + Effect.gen(function* () { + active.clear() + yield* State.batch(Scope.close(scope, exit)) }), + ) + + 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 735ddd310..9a763c7ea 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 3acd94a21..34b52417d 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 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 host = yield* PluginHost.make(plugin) - 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")) + 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* 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 121bc0e6c..cbafd68b5 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 833671c0a..27afc14d2 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 000000000..ba7e248f6 --- /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 34f466850..04f1f092a 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 000000000..58fb4ba0d --- /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 ea3939b75..1749b474e 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 a75d0c0d0..c5c4be0d0 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 fe7bc1036..0995cf1c1 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 7c36d6dd9..cf883a068 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 9115dcefe..2e1f9d9b4 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 f82f3eacc..0fd651160 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 d6ba76db6..d416f6f19 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 3904ee5b8..1a1c533eb 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 df9f64685..0ca070857 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 2f62029a5..1b23e08ba 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 4ab7c738d..c84a6ed51 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 6e8f91861..f097dcaca 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 6adc366c0..682579d7a 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 70af07164..8723cdaac 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 e3d429504..4e643c9f5 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 19b240b70..476af5b91 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 6a6e14ae6..0bddb4430 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 f57322a90..6ee6670ee 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 5c9802065..eafc5edd6 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 a799c2b45..a73197565 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 25f695d95..449599727 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 de2da085f..d602ed0ff 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 07fb2fec9..c1734d62a 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 1414d5a1e..d50992b51 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 81c4911d9..0f295fb09 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 c9e1873de..44c1ef2fc 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 b3675961b..8c668d8b4 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 48e5e73aa..788ac63eb 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 10eb849ba..8022e0de6 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 2d2bc4dc9..1a602ffd5 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 45f117158..00f560143 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 5fc10e867..8145a3480 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 497561e00..29adebc0e 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 1dec8ba35..0e9c85abe 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 0e3246b3b..441c380d7 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 5ed46d76e..9572f55a2 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 f56726476..fa3423c58 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 968933a6b..68e4ba5e6 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 158fb4fab..31e3d60c2 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 92fb4c0a6..81a33f016 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 1c540e0e9..ab3457fc1 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 589a99d46..ce25e7852 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 f8b8d4eb2..ef8d0747d 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 bb4b256f8..9890f7e79 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 bc84d9cdb..11707c202 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 000000000..e26e12bda --- /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 000000000..e6944368c --- /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 19311363e..12f4a01c7 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 2f86714bb..e3cbfd557 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 0b3e0c8e5..67e811558 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 d8fe74336..a662ed7ca 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", () => - 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) - - 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", () => + it.effect("reconciles transformed plugins", () => 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 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), - }), - { 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 d9d68e98b..d4e2500c2 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 3faa65a65..4062d37f7 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 000000000..a5f12a113 --- /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 000000000..ed53e4b94 --- /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 000000000..b1c6ea436 --- /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 eb11dc30d..02bce652c 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" }, - }, - npm: { - add: () => Effect.die("unused npm.add"), + plugin: overrides.plugin ?? { + transform: () => Effect.die("unused plugin.transform"), + reload: () => Effect.die("unused plugin.reload"), }, - 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"), + reload: () => Effect.die("unused skill.reload"), }, - ...overrides, } } -export function aisdkHost(plugin: PluginV2.Interface): PluginHost["aisdk"] { +export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { 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, + 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 c872b6fe6..4c3071c77 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 000000000..41a664194 --- /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 5fb8b16bf..cda7b23e2 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 1a2512485..b6ef65f1a 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 ba3a33915..8cf8d1555 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 2c1c7ec87..08e878801 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 10c2a005d..b479435e5 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 5501ad39e..f741041e5 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 31ce4448f..5bc8a3d5f 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 f6da837d8..c911f740f 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 f0d09b841..0ee465d6f 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 db7dd1042..d428b9b7e 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 150c9ea84..548b1a92e 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 619e184d8..9179a0b04 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 b8f615f93..03d644132 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 1940bba93..f7c75a7d1 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 } }, + }, + }), + 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", { 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 fe9b0b0d9..511cb7ac0 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 f5f62f8df..b66ce1435 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 1197957f5..620f01e9c 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 dbc97205b..4f469e95c 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 5e7a7c2d2..1df0fd315 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 0fc22c523..d7f9d0d73 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 f09e0e62c..0544d5f50 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 ee16e5a2b..a1c05df33 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 c0601c2ba..74adb0ef4 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 a9911d2cc..867de3a48 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 750b71463..b634d0fee 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 5761827c4..d05f5721c 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 eeb00093e..7044b55e5 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 6892aaf6a..ab4840990 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 language = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai"), - sdk: () => { - throw new Error("SAP AI Core should ignore other providers") - }, - options: {}, + 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") }, - {}, - ) + 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 ca839fff5..a2b6371a3 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 b780124a6..439a26e66 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 639543af5..a8a7c1f0b 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 5abc737dd..3611172e9 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 a978381de..b5f79e4c0 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 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 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* 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 3313cd048..b9d34a1f5 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 9fefec820..19ce82d81 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 5e1aba192..621f9a676 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 fce6ea108..9b35d9083 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 70d522c3f..505cd724e 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 3a54be6c0..2848fc37a 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 b1430314f..8e6480538 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 02ad579ac..87a67b4bc 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 74401779d..49b790185 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 16a0a6b5e..ef2134750 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 4fbf469d8..3da1d7b56 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. - -```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. +## Defining A Plugin ```ts -import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" +import { define } 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, - } - }) - }, - }), +export const Plugin = define({ + id: "example", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }), }) ``` -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`: +Plugin setup registers hooks imperatively. It does not return a hook object. -```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 -``` +Configuration supplied for the plugin is available as `ctx.options`. -Repeated invalidations are serialized and may be coalesced. +Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`. -## Models.dev Example +## Transform Hooks -Models.dev is the main example of a dynamic plugin. It projects one changing source into the integration and catalog domains. +Transform hooks contribute to stateful 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), - } - }), -}) +yield * + ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) + }) ``` -The watcher performs I/O. The transforms only project the latest in-memory snapshot. - -## Skill Directory Watching +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. -A skill plugin follows the same pattern. +Available transform hooks are namespaced by domain: ```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) - }, - } - }), -}) +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform ``` -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. +Runtime hooks intercept live operations rather than rebuilding domain state: ```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, + 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) + }), ) -``` - -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 +yield * + ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) + }) ``` -The setup scope owns: - -- Event subscriptions -- Watchers -- Background fibers -- Plugin hooks - -Closing the scope unloads the plugin and invalidates its transformed domains. +Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. -## Promise API +## Reloading A Domain -The Promise API uses the same SDK values, hook names, editors, and lifecycle semantics. +When data captured by a transform changes, reload the affected domain: ```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 - }) - }, - }), -}) -``` +let data = yield * loadCatalog() -Promise plugins receive Promise-returning host capabilities: +yield * + ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) + }) -```ts -await ctx.catalog.invalidate() +data = yield * loadCatalog() +yield * ctx.catalog.reload() ``` -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. +Reload belongs to the domain, not an individual registration. `ctx.catalog.reload()` reruns every active catalog transform and publishes the rebuilt catalog. -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. +Available reload operations are: ```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.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() ``` - -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 index 11a8c6c20..9ded7b831 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 579de8249..ddc292e38 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 1d44717ae..704ef16bf 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 fcb90d196..0afd4bafe 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 000000000..76ba79674 --- /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 707f744b3..000000000 --- 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 46c457451..928649c05 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 2acb08b57..5caec6888 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 09c919ad6..75008d92e 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 389674cff..c1e263047 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 05aa0c4b6..dfe562639 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 d25a71f0d..88dd3867d 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 000000000..2b210f943 --- /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 000000000..e91b93fbd --- /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 000000000..bec589146 --- /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 000000000..ddc292e38 --- /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 000000000..70842e94b --- /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 000000000..cdc5f8268 --- /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 000000000..76ba79674 --- /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 000000000..41254707f --- /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 000000000..06afa09d2 --- /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 000000000..f7ff2ad45 --- /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 000000000..f4b7f8b83 --- /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 000000000..5e0ae7f48 --- /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 000000000..fbfa1a4a6 --- /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 cbde76dbc..3be2c9d5e 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 71a3f9d85..542717351 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 8b1c9959e..4d84179ed 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 097/259] 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 f71b01042..ef1c0142f 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 14c8b94dd..932cb8a49 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 098/259] 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 9c34e336b..6b16ab74b 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 099/259] 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 cdaefe2cf..89ca95002 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 34b52417d..000000000 --- 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 ba7e248f6..9b66a7b6a 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 58fb4ba0d..9543220ae 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 100/259] refactor(plugin): use direct runtime registry --- packages/core/src/config/plugin/external.ts | 10 +-- packages/core/src/plugin.ts | 82 ++++++++++----------- 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, 129 insertions(+), 167 deletions(-) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index d81d9f7c8..d8ace63b3 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 85c51ea7d..bbaa7dead 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) + 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.gen(function* () { - const existing = active.get(id) - if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + 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* 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 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 27afc14d2..f6c0e24e0 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 9b66a7b6a..6aeef4dc3 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 9543220ae..49a19af4c 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 67e811558..5bb064b5f 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 a662ed7ca..b787c754d 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 02bce652c..62fa8391e 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 76ba79674..9089334ee 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 928649c05..f13614a54 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 75008d92e..7352797b8 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 76ba79674..9089334ee 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 41254707f..23394fa01 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 f7ff2ad45..ab59fb95f 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 101/259] 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 5776e1bf2..13a76c0a1 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 17a9a554c..39aa35993 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 f2916173d..2a16017a2 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 000000000..0f3fca135 --- /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 8cf77aab9..52b1bee89 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 e3758374c..11d8b4765 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 102/259] 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 9f33cd5b9..4cf6b2d44 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 103/259] 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 1fc8571db..5a9933e99 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 925614cc2..a03a75074 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 bd6f175ad..f86cbdd69 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 0fb2cd735..9bbea5f0c 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 d0e57cc2e..84ad2fdb7 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 52b29528b..9a717ba4e 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 dad796c99..a1f4d95c3 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 620378dc1..1d3fb075f 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 bec50d98d..b576b7729 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 f1500ec44..52ff5a354 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 705e0250d..b685fb679 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 18ae82d4d..e3356d183 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 9c22e0041..d049041fa 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 8a3701e12..98233f352 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 b187b191c..51ff867ea 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 4e56c61d2..9c540daad 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 2ea1d1458..a3c6ca27b 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 11d8b4765..b368110f4 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 c787a84a8..766ed3c11 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 13110be0b..46c48ef32 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 00e4ef5c9..8ba730906 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 213a48e11..2477ff99b 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 bbc3d2956..47ae745e4 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 3a59c839a..a189e3d0f 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 f8130ecc6..14fd6df6c 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 34cfe3f42..67099196a 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 cb90e305e..bfe7efd89 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 104/259] 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 5a9933e99..58d50e331 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 1d3fb075f..1e4423e01 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 a3c6ca27b..7b98e721d 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 b368110f4..b57a1e5dd 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 105/259] 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 8a7fd8ca7..39b237d70 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 f8377f718..925112414 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 106/259] 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 bbaa7dead..4bb40934a 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 b787c754d..1e2789765 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 8e6480538..dfb838fac 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 49b790185..35ef47ab1 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 dcb260588..bf6151268 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 107/259] 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 63276bfe1..f2ee0b65d 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 108/259] 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 6aeef4dc3..e170a85f1 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 109/259] 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 dfb838fac..cb2393813 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 35ef47ab1..49b790185 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 110/259] 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 a2626b113..3a14d0d7e 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 6b16ab74b..dd03c786a 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 111/259] 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 55377f847..ed882ade4 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 d049041fa..a01cc9d12 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 112/259] 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 dd03c786a..d3a4493ce 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 895659a6f..63cc3edb2 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 113/259] 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 d3a4493ce..6de9033ff 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 114/259] 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 | 105 +++--- 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 | 321 ++++++++---------- 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, 630 insertions(+), 378 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 58d50e331..3dce37caf 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 a03a75074..dba7eae7e 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 75f0c6b44..e2f6108fd 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 a95f0a603..621723ae1 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 827714320..331285639 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) - - ref.scrollIntoView({ behavior: "instant" }) - }} - onClose={() => tabsStoreActions.removeTab(i())} - active={currentTab() === tab} - activeServer={tab.server === server.key} - forceTruncate={tabsAreOverflowing()} - /> + + {(session) => ( + { + tabs.select(tab) + + 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 71186a55f..afc59b595 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 14ed7466c..f7668c194 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 53bf40e70..edd58ad7a 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 0814dbce7..ca0ea5f86 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 dce3a4049..ff43638bb 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 4a62c2a8d..62818550d 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 393875ff0..1a4a47597 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 d1aa61c4c..a9c66c483 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 f937c98fa..d9d5a2edc 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 24c7e4c3a..e9f036993 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 000000000..2f8793d8d --- /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 4177d4919..cb3a4bf6e 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() ?? ""} - - - - -
-
-
- - - - - - +
+ {autoselecting() ?? ""} + + + + +
+
+
+ + + ) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 72625a615..011094415 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@/utils/toast" -import { checksum } from "@opencode-ai/core/util/encode" +import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useLocation, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" @@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline" import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" -import { useServer } from "@/context/server" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" @@ -92,7 +91,6 @@ export default function Page() { const prompt = usePrompt() const comments = useComments() const terminal = useTerminal() - const server = useServer() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() @@ -137,11 +135,11 @@ export default function Page() { layout.handoff.clearTabs() return } - if (pending.scope !== server.scope()) return + if (pending.scope !== serverSDK().scope) return if (pending.id !== id) return layout.handoff.clearTabs() - if (pending.dir !== (params.dir ?? "")) return + if (pending.dir !== base64Encode(sdk().directory)) return const from = workspaceTabs().tabs() if (from.all.length === 0 && !from.active) return @@ -247,7 +245,7 @@ export default function Page() { createEffect( on( - () => ({ dir: params.dir, id: params.id }), + () => ({ dir: sdk().directory, id: params.id }), (next, prev) => { if (!prev) return if (next.dir === prev.dir && next.id === prev.id) return @@ -575,7 +573,7 @@ export default function Page() { createEffect( on( - () => params.dir, + () => sdk().directory, (dir) => { if (!dir) return setStore("newSessionWorktree", "main") diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index eab0a1ee1..3fbde9ef3 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -29,6 +29,7 @@ import { useServer } from "@/context/server" import { useTabs } from "@/context/tabs" import { useDirectoryPicker } from "@/components/directory-picker" import { base64Encode } from "@opencode-ai/core/util/encode" +import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" export function SessionComposerRegion(props: { state: SessionComposerState @@ -200,7 +201,11 @@ export function SessionComposerRegion(props: { const openParent = () => { const id = parentID() if (!id) return - navigate(`/${route.params.dir}/session/${id}`) + navigate( + route.params.serverKey + ? sessionHref(requireServerKey(route.params.serverKey), id) + : legacySessionHref(sdk().directory, id), + ) } createEffect(() => { diff --git a/packages/app/src/pages/session/session-layout.ts b/packages/app/src/pages/session/session-layout.ts index 82be2bf5c..1adf6c1a9 100644 --- a/packages/app/src/pages/session/session-layout.ts +++ b/packages/app/src/pages/session/session-layout.ts @@ -1,15 +1,19 @@ import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" import { useLayout } from "@/context/layout" -import { useServer } from "@/context/server" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" +import { base64Encode } from "@opencode-ai/core/util/encode" export const useSessionKey = () => { const params = useParams() - const server = useServer() - const scope = createMemo(() => server.scope()) - const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir))) - const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id))) + const sdk = useSDK() + const serverSDK = useServerSDK() + const scope = createMemo(() => serverSDK().scope) + const directory = createMemo(() => base64Encode(sdk().directory)) + const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory()))) + const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id))) return { params, sessionKey, workspaceKey } } diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 6943a9a3e..533d6b3d8 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSettings } from "@/context/settings" import { useTerminal } from "@/context/terminal" +import { useSDK } from "@/context/sdk" import { terminalTabLabel } from "@/pages/session/terminal-label" import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" @@ -25,10 +26,11 @@ export function TerminalPanel() { const delays = [120, 240] const layout = useLayout() const terminal = useTerminal() + const sdk = useSDK() const language = useLanguage() const command = useCommand() const settings = useSettings() - const { params, workspaceKey, view } = useSessionLayout() + const { workspaceKey, view } = useSessionLayout() const opened = createMemo(() => view().terminal.opened()) const size = createSizing() @@ -122,7 +124,7 @@ export function TerminalPanel() { }) createEffect(() => { - const dir = params.dir + const dir = sdk().directory if (!dir) return if (!terminal.ready()) return language.locale() @@ -140,7 +142,7 @@ export function TerminalPanel() { }) const handoff = createMemo(() => { - const dir = params.dir + const dir = sdk().directory if (!dir) return [] return getTerminalHandoff(workspaceKey()) ?? [] }) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 5a26f1781..0d7059704 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout" import { useServerSDK } from "@/context/server-sdk" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" +import { useTabs } from "@/context/tabs" +import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" @@ -260,6 +262,7 @@ export function MessageTimeline(props: { const sdk = useSDK() const sync = useSync() const settings = useSettings() + const tabs = useTabs() const dialog = useDialog() const language = useLanguage() const { params, sessionKey } = useSessionKey() @@ -757,12 +760,18 @@ export function MessageTimeline(props: { const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { if (params.id !== sessionID) return + const href = (id: string) => + params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id) if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) + navigate(href(parentID)) return } if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) + navigate(href(nextSessionID)) + return + } + if (params.serverKey) { + tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory }) return } navigate(`/${params.dir}/session`) @@ -864,7 +873,9 @@ export function MessageTimeline(props: { const navigateParent = () => { const id = parentID() if (!id) return - navigate(`/${params.dir}/session/${id}`) + navigate( + params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id), + ) } function DialogDeleteSession(props: { sessionID: string }) { diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index e3f0c517d..f64c5c26a 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" +import { useTabs } from "@/context/tabs" +import { requireServerKey } from "@/utils/session-route" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const settings = useSettings() const sync = useSync() const terminal = useTerminal() + const sessionTabs = useTabs() const layout = useLayout() const navigate = useNavigate() const { params, tabs, view } = useSessionLayout() @@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.new"), keybind: "mod+shift+s", slash: "new", - onSelect: () => navigate(`/${params.dir}/session`), + onSelect: () => { + if (params.serverKey) { + sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory }) + return + } + navigate(`/${params.dir}/session`) + }, }), sessionCommand({ id: "session.undo", diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts new file mode 100644 index 000000000..42ead3b5f --- /dev/null +++ b/packages/app/src/utils/session-route.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { ServerConnection } from "@/context/server" +import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route" + +describe("session routes", () => { + test("builds and decodes a server-keyed session route", () => { + const server = ServerConnection.Key.make("https://example.com:4096") + const href = sessionHref(server, "session-1") + + expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1") + expect(requireServerKey(href.split("/")[2])).toBe(server) + }) + + test("rejects malformed server keys", () => { + expect(() => requireServerKey("not-base64")).toThrow("Invalid server route") + }) + + test("builds the legacy directory-keyed route", () => { + expect(legacySessionHref("/Users/example/project", "session-1")).toBe( + "/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1", + ) + }) + + test("resolves the root session", async () => { + const sessions: Record = { + child: { id: "child", parentID: "parent" }, + parent: { id: "parent", parentID: "root" }, + root: { id: "root" }, + } + + expect( + await rootSession(sessions.child, async (id) => { + const session = sessions[id] + if (!session) throw new Error(`Missing session: ${id}`) + return session + }), + ).toBe(sessions.root) + }) +}) diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts new file mode 100644 index 000000000..1bf921954 --- /dev/null +++ b/packages/app/src/utils/session-route.ts @@ -0,0 +1,25 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { ServerConnection } from "@/context/server" +import { decode64 } from "@/utils/base64" + +export function sessionHref(server: ServerConnection.Key, sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +export function legacySessionHref(directory: string, sessionID: string) { + return `/${base64Encode(directory)}/session/${sessionID}` +} + +export function requireServerKey(segment: string | undefined) { + const key = decode64(segment) + if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route") + return ServerConnection.Key.make(key) +} + +type SessionParent = { id: string; parentID?: string } + +export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise) { + let current = session + while (current.parentID) current = await get(current.parentID) + return current +} From 09757c605a567e19d48dbba1674983ce47deef34 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 06:20:50 +0000 Subject: [PATCH 115/259] chore: generate --- packages/app/src/context/tabs.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 1a4a47597..774a21d9b 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -183,8 +183,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ removeSessions: (input: SessionTabsRemovedDetail) => { const removed = store .filter( - (tab) => - tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId), + (tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId), ) .map(tabKey) void startTransition(() => { From 597f47b4864acf83bf4d50314a4847b65878318c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:00:21 +0800 Subject: [PATCH 116/259] fix(app): improve mobile home layout (#32789) --- packages/app/src/pages/home.tsx | 64 ++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index e9f036993..cbb9c71ba 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -329,7 +329,7 @@ export function NewHome() { return (
-
+
+ platform.openLink("https://opencode.ai/desktop-feedback")} + language={language} + />
) @@ -443,7 +449,7 @@ function HomeProjectColumn(props: { const dialog = useDialog() const controller = useServerManagementController({ navigateOnAdd: false }) return ( -