Compare commits

..
157 changed files with 2385 additions and 6809 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Preserve provider metadata on assistant text when replaying conversation history through AI SDK providers.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/theme": patch
---
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Align the built-in review and system prompts with available documentation and code-search tools and the current Plan mode reminder.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Correct the compaction prompt to point existing summaries at the conversation history that follows them.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Correct the published Newtype documentation to recommend `Schema.decodeUnknownSync` for schema-based synchronous decoding.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Propagate fatal Git ignore-check errors during index refresh so best-effort snapshot capture returns unavailable instead of treating the check as having no matches.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": major
---
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Rename the write, patch, and question tool formatting helpers from `toModelOutput` to `toModelContent` to match the result field they populate. Direct imports of these helpers must use the new name; generated content and declared machine output are unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": minor
---
Rename ModelResolver.supported to ModelResolver.hasPackage. Consumers of the old export must update the name; the predicate remains Boolean(model.package), checking only whether a catalog model declares a provider package, not whether it can be loaded. Default-model selection behavior is unchanged.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Forward plugin Session interrupt options so continuation requests reach the Session service.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/plugin": patch
---
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Remove the unused question request API and use session forms for question tool interactions.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Wake idle sessions when recovering background shell outcomes after a server restart. Admit shell outcomes before resuming background child sessions, while preserving restart retry budgets and completion-driven parent notifications.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Preserve explicit function tool strictness in Copilot Responses requests.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
+9
View File
@@ -0,0 +1,9 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
external directories, while the default tree-sitter path remains unchanged.
+10
View File
@@ -0,0 +1,10 @@
---
"@opencode-ai/core": patch
---
Make the experimental portable shell scanner authoritative, with no Tree-sitter
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
expressions, quoting, and substitutions natively. Preserve existing redirect and
declaration permission matching, and make PowerShell saved approvals cover the
original command spelling. Parser failures remain visible without changing the
permission engine. The default Tree-sitter path is unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Use parent directory names for SKILL.md IDs so individually downloaded skills do not overwrite each other.
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": patch
"@opencode-ai/app": patch
---
Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed.
Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
---
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Keep Google Vertex catalog settings serializable during model projection.
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
+15 -29
View File
@@ -185,7 +185,7 @@
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
"effect": "4.0.0-rc.111",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -506,7 +506,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/platform-node-shared": "4.0.0-rc.111",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -966,7 +966,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -976,7 +975,6 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -1064,10 +1062,10 @@
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-rc.112",
"@effect/platform-node": "4.0.0-rc.112",
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.13",
@@ -1107,7 +1105,7 @@
"dompurify": "3.4.14",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.112",
"effect": "4.0.0-rc.111",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
@@ -1666,13 +1664,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "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-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "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-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.111", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.111", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.111", "redis": ">=5.0.0 <7.0.0" } }, "sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.112", "", { "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-EqR8pWZo3VzedRvP6qo5HcfqNmFvSa+sQLWQl3oBMx2KY1FCc8NYjep79wYjNkW0qABnffrPUXCMTOncd1A+5g=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="],
@@ -3836,7 +3834,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
"effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5814,7 +5812,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -6318,10 +6316,6 @@
"@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-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
@@ -6644,6 +6638,8 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -6900,8 +6896,6 @@
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
@@ -7450,12 +7444,6 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8162,8 +8150,6 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Fmwqp/fjTMX7gQW01Zgta51reLHp1S53GSQgBcJePrY=",
"aarch64-linux": "sha256-KLI6OIbvycMePKxt66nNKeILYJSEkufOIaSawZ/gotc=",
"aarch64-darwin": "sha256-go1wmrsHfYfJ3ukUIR/fveobyVba4E9G/rdsB/J2cQQ=",
"x86_64-darwin": "sha256-UnVYMijlG12k4RLFkTwf4Or0yXqFSwe1Ji6QCGufjzg="
"x86_64-linux": "sha256-vn4haT0uuD5tCbMD2F5IuDj+/XS/U/sVQ0bk5xJCbIM=",
"aarch64-linux": "sha256-XUGJ6hE7xvIyT53k5uCqaF/eGU4d4tIbuVuGrHU12Fg=",
"aarch64-darwin": "sha256-o3Os6rO8nGVVYs1aJBJAdYQaLbDsYNtGub4Gg1gHRQU=",
"x86_64-darwin": "sha256-YB00nH7WmgqRXvzA+zYEbaaik/vKheu3n/vJ6Ombx9k="
}
}
+5 -5
View File
@@ -39,10 +39,10 @@
"packages/stats/*"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.112",
"@effect/platform-node": "4.0.0-rc.112",
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -76,7 +76,7 @@
"dompurify": "3.4.14",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.112",
"effect": "4.0.0-rc.111",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -84,15 +84,12 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
packages: {
resolve: (spec, install = true, revision) =>
resolve: (spec, install = true) =>
runPromise(
install
? npm.add(spec, { subpaths: ["tui"], revision })
: npm.resolve(spec, { subpaths: ["tui"], revision }),
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
check: (spec) => runPromise(npm.check(spec)),
update: (spec) => runPromise(npm.update(spec, { subpaths: ["tui"] })),
reload: (spec, options) => runPromise(npm.reload(spec, { subpaths: ["tui"], ...options })),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
+1 -1
View File
@@ -37,7 +37,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
"effect": "4.0.0-rc.111",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
-24
View File
@@ -88,32 +88,8 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export type PluginReloadInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginReloadOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginReloadOperation<E = never> = (input: PluginReloadInput) => Effect.Effect<PluginReloadOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
readonly reload: PluginReloadOperation<E>
}
export type SessionListInput = {
+1 -33
View File
@@ -15,12 +15,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginReloadInput,
PluginReloadOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -322,33 +316,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginReload = (raw: RawClient["server.plugin"]) => (input: PluginReloadInput) =>
preserveEffect<PluginReloadOutput>()(
raw["plugin.reload"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
reload: EndpointPluginReload(raw),
})
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,12 +9,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginReloadInput,
PluginReloadOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -466,45 +460,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
reload: (input: PluginReloadInput, requestOptions?: RequestOptions) =>
request<PluginReloadOutput>(
{
method: "POST",
path: `/api/plugin/reload`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+2 -60
View File
@@ -16,8 +16,6 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -435,24 +433,8 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| {
id: string
source: PluginSource
status: "active"
tui: boolean
revision?: string
generation?: string
error?: string
}
| {
id?: string
source: PluginSource
status: "failed"
error: string
tui: boolean
revision?: string
generation?: string
}
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type SessionMessageLocationSwitched = {
id: string
@@ -2348,10 +2330,6 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type PluginOperationError = { readonly _tag: "PluginOperationError"; readonly message: string }
export const isPluginOperationError = (value: unknown): value is PluginOperationError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginOperationError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2562,42 +2540,6 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PluginPackageStatus
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type PluginReloadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginReloadOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
+1 -1
View File
@@ -19,7 +19,7 @@
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside CodeMode) instead.
- Improve the failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- Report host failure messages and underlying causes rather than replacing them with generic diagnostics. Preserve interruption behavior.
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
- If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool.
+2 -2
View File
@@ -139,8 +139,8 @@ Diagnostic kinds:
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning only: additional warnings were omitted by `maxOutputBytes`. |
Host failures and defects report their messages and underlying causes. Invalid outputs include the validation or
copying error. Interruption propagates without becoming an error diagnostic.
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` explicitly exposes a
safe refusal to the model; its optional cause remains private.
## Discovery
+2 -2
View File
@@ -365,7 +365,7 @@ ultimate source of truth.
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
- [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from sanitized internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
reasons.
+2 -2
View File
@@ -1,11 +1,11 @@
import { Schema } from "effect"
/** Tool failure reported as `ToolFailure`. */
/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */
export class ToolError extends Schema.TaggedError<ToolError>()("ToolError", {
message: Schema.String,
cause: Schema.optionalKey(Schema.Defect()),
}) {}
/** Creates a tool failure with an optional underlying cause. */
/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */
export const toolError = (message: string, cause?: unknown): ToolError =>
new ToolError({ message, ...(cause === undefined ? {} : { cause }) })
+24 -18
View File
@@ -1,5 +1,5 @@
import { Cause, Effect, Exit, Formatter, Schema } from "effect"
import { toolError } from "./tool-error.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
decodeOutput as decodeToolOutput,
@@ -117,6 +117,15 @@ export class ToolRuntimeError extends Error {
}
}
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error))
}),
)
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
@@ -498,12 +507,19 @@ export const make = <R>(
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
const message =
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
const decodeOutput = (value: unknown, name: string) =>
Effect.try({
try: () => copyIn(value, `Result from tool '${name}'`),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`)
@@ -532,22 +548,12 @@ export const make = <R>(
return yield* observeEnd(
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
return Effect.fail(
toolError(
Cause.prettyErrors(cause)
.map((error) => (error.cause ? Formatter.format(error) : error.message || error.name))
.join("\n"),
),
)
}),
)
return yield* Effect.try({
try: () => copyIn(decodeToolOutput(tool, raw), `Result from tool '${name}'`),
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
return yield* decodeOutput(result, name)
}),
call,
)
+22 -23
View File
@@ -5,15 +5,15 @@ import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class HostError extends Schema.TaggedError<HostError>()("HostError", {
message: Schema.String,
class UnsafeHostError extends Schema.TaggedError<UnsafeHostError>()("UnsafeHostError", {
reason: Schema.String,
}) {}
describe("CodeMode host failure boundary", () => {
test("preserves explicit tool failures", async () => {
test("preserves explicit safe tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail",
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("Authorized request was refused")),
@@ -26,10 +26,10 @@ describe("CodeMode host failure boundary", () => {
})
})
test("does not rewrite explicit tool failures", async () => {
test("does not rewrite explicit safe tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail",
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
@@ -42,15 +42,10 @@ describe("CodeMode host failure boundary", () => {
})
})
test("reports failures, defects, rejected Promises, and nested causes", async () => {
test("sanitizes unknown host failures and defects", async () => {
for (const failure of [
Effect.fail(new HostError({ message: "Connection refused" })),
Effect.die(new Error("Connection refused")),
Effect.promise(async () => {
throw new Error("Connection refused")
}),
Effect.fail(toolError("Request failed", new Error("Connection refused"))),
Effect.failCause(Cause.combine(Cause.fail("Request failed"), Cause.die("Connection refused"))),
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
Effect.die(new Error("postgres://user:defect-secret@example.invalid")),
]) {
const result = await run(
Tool.make({
@@ -63,28 +58,31 @@ describe("CodeMode host failure boundary", () => {
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "ToolFailure",
message: expect.stringContaining("Connection refused"),
message: "Tool execution failed",
})
expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/)
}
})
test("reports invalid host output", async () => {
test("sanitizes invalid host output", async () => {
const secret = "invalid-output-secret"
const result = await run(
Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.succeed({ value: 1 } as unknown as { readonly value: string }),
output: Schema.Struct({ safe: Schema.String }),
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
}),
)
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "InvalidToolOutput",
message: "Invalid output from tool 'host.call': SchemaError(Expected string\n at [\"value\"])",
message: "Invalid output from tool 'host.call'.",
})
expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/)
})
test("reports host output copying errors", async () => {
test("sanitizes host output that throws while being copied", async () => {
const result = await run(
Tool.make({
description: "Return hostile output",
@@ -96,7 +94,7 @@ describe("CodeMode host failure boundary", () => {
{},
{
ownKeys: () => {
throw new Error("Cannot enumerate output")
throw new Error("host-output-secret")
},
},
),
@@ -106,8 +104,9 @@ describe("CodeMode host failure boundary", () => {
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "InvalidToolOutput",
message: "Invalid output from tool 'host.call': Error: Cannot enumerate output",
message: "Invalid output from tool 'host.call'.",
})
expect(JSON.stringify(result)).not.toMatch(/host-output-secret/)
})
test("caught tool failures are Error values in-program", async () => {
@@ -230,7 +229,7 @@ describe("CodeMode tool-call observation", () => {
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Tool execution failed" },
])
})
+1 -1
View File
@@ -87,7 +87,7 @@ const layer = Layer.effect(
draft.agents.delete(id)
},
}),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("Catalog.finalize")(function* () {
notify: Effect.fn("Catalog.notify")(function* () {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+3 -1
View File
@@ -65,7 +65,9 @@ export const create = (
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const executed = yield* executeTool(name, tool, input, context)
const executed = yield* executeTool(name, tool, input, context).pipe(
Effect.mapError((failure) => toolError(failure.message, failure)),
)
const content =
typeof executed.content === "string"
? [{ type: "text" as const, text: executed.content }]
+1 -1
View File
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
@@ -25,19 +25,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let current: readonly string[] = []
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -56,7 +52,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => current,
current: () => state.get().ignore,
observe,
})
}),
-2
View File
@@ -23,7 +23,6 @@ import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { PluginExecution } from "./plugin/execution.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
@@ -68,7 +67,6 @@ const nodes = [
AISDK.node,
Plugin.node,
PluginHooks.node,
PluginExecution.node,
InstancePlugins.node,
PluginSupervisor.node,
Worktree.refreshNode,
+1 -1
View File
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
draft.available = false
},
}),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+8 -6
View File
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
@@ -402,11 +402,13 @@ const layer = Layer.effect(
}
yield* Effect.gen(function* () {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
const persistence = yield* Effect.sync(() => {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
return attempt.label ?? implementation?.label?.(exit.value)
}).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
+7 -5
View File
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Semaphore, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -613,8 +613,10 @@ export const layer = (options?: Options) =>
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
const reconcileLock = Semaphore.makeUnsafe(1)
const reconcile = Effect.fnUntraced(function* () {
if (root.state._tag === "Closed") return
const servers = new Map(state.get().servers)
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
@@ -676,7 +678,7 @@ export const layer = (options?: Options) =>
Effect.ignore,
),
)
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -701,7 +703,7 @@ export const layer = (options?: Options) =>
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
finalize: reconcile,
notify: () => reconcileLock.withPermit(reconcile()),
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
+44 -107
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source, PackageStatus, OperationError } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -26,7 +26,6 @@ import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
import { PluginExecution } from "./plugin/execution.js"
export interface Interface {
readonly activate: (
@@ -39,8 +38,6 @@ export interface Interface {
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly revision?: string
readonly generation?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -50,12 +47,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const execution = yield* PluginExecution.Service
const scope = yield* Scope.make()
const active = new Map<
Plugin.ID,
{ readonly plugin: Versioned; readonly scope: Scope.Closeable; readonly error?: string }
>()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
@@ -82,7 +75,7 @@ const layer = Layer.effect(
"plugin.id": plugin.id,
cause: loaded.cause,
})
return { error: errorMessage(loaded.cause) } as const
return { error: Cause.pretty(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (
@@ -105,84 +98,55 @@ const layer = Layer.effect(
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
})
) {
const nextInventory = withFailures(
Array.from(active.values(), (entry) => activeInfo(entry.plugin, entry.error)),
failures,
)
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* execution.exclusive(
State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
// Registrations are append-ordered. Preserve the unchanged prefix, then rebuild the suffix.
const current = Array.from(active.values(), (entry) => entry.plugin)
const prefix = definitions.findIndex(
(definition, index) =>
definition.id !== current[index]?.id || definition.version !== current[index]?.version,
)
const keep = prefix === -1 ? definitions.length : prefix
const previousEntries = new Map(active)
const closing = Array.from(active.entries()).slice(keep).toReversed()
yield* Effect.forEach(
closing,
([id, entry]) =>
Effect.gen(function* () {
yield* Scope.close(entry.scope, Exit.void).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to clean up plugin", { "plugin.id": id, cause }),
),
)
active.delete(id)
}),
{ discard: true },
)
for (const [index, definition] of definitions.entries()) {
if (index < keep) {
nextInventory.push(activeInfo(definition, active.get(definition.id)?.error))
continue
}
const previous = previousEntries.get(definition.id)
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
const failed: Plugin.Info = {
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
...(definition.revision === undefined ? {} : { revision: definition.revision }),
...(definition.generation === undefined ? {} : { generation: definition.generation }),
}
if (!previous) {
nextInventory.push(failed)
continue
}
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope, error: loaded.error })
nextInventory.push({ ...activeInfo(previous.plugin), error: loaded.error })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
nextInventory.push(failed)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
inventory = withFailures(nextInventory, failures)
}),
).pipe(Effect.uninterruptible),
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
}
const removed = Array.from(active.entries())
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
}),
@@ -207,41 +171,15 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned, error?: string): Plugin.Info {
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
...(plugin.generation === undefined ? {} : { generation: plugin.generation }),
...(error === undefined ? {} : { error }),
}
}
/** Inventory errors must not expose credentials or unbounded import/setup traces. */
export function errorMessage(cause: Cause.Cause<unknown>) {
return Cause.pretty(cause)
.replace(/https?:\/\/[^\s"'<>]+/g, (value) => {
if (!URL.canParse(value)) return "[URL]"
const url = new URL(value)
url.username = ""
url.password = ""
url.search = ""
url.hash = ""
return url.href
})
.slice(0, 2000)
}
function withFailures(active: Plugin.Info[], failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[]) {
return failures.reduce((inventory, failure) => {
const index = inventory.findIndex((entry) => JSON.stringify(entry.source) === JSON.stringify(failure.source))
if (index === -1) return [...inventory, failure]
return inventory.map((entry, current) => (current === index ? { ...entry, error: failure.error } : entry))
}, active)
}
export const node = makeLocationNode({
service: Service,
layer,
@@ -265,6 +203,5 @@ export const node = makeLocationNode({
WebSearch.node,
Generate.node,
Permission.node,
PluginExecution.node,
],
})
-74
View File
@@ -1,74 +0,0 @@
export * as PluginExecution from "./execution.js"
import { Context, Effect, Latch, Layer, Semaphore } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
export interface Interface {
readonly lease: <A, E, R>(
session: { readonly id: string; readonly parentID?: string; readonly admission?: boolean },
effect: Effect.Effect<A, E, R>,
) => Effect.Effect<A, E, R>
readonly exclusive: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginExecution") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const admission = yield* Latch.make(true)
const idle = yield* Latch.make(true)
const mutations = yield* Semaphore.make(1)
const readers = new Map<string, number>()
let leases = 0
const acquire = (session: {
readonly id: string
readonly parentID?: string
readonly admission?: boolean
}): Effect.Effect<void> =>
Effect.suspend(() => {
// Child execution must finish the parent's tool call even while activation waits.
if (
!admission.isOpen() &&
!readers.has(session.id) &&
!(session.parentID && readers.has(session.parentID)) &&
!(session.admission && leases > 0)
)
return Effect.interruptible(admission.await).pipe(Effect.andThen(acquire(session)))
// Admission protects callbacks but does not establish Session execution ownership.
if (!session.admission) readers.set(session.id, (readers.get(session.id) ?? 0) + 1)
leases++
return idle.close
})
return Service.of({
lease: (session, effect) =>
Effect.acquireUseRelease(
acquire(session),
() => effect,
() =>
Effect.suspend(() => {
leases--
if (!session.admission) {
const count = (readers.get(session.id) ?? 1) - 1
if (count === 0) readers.delete(session.id)
else readers.set(session.id, count)
}
return leases === 0 ? idle.open : Effect.void
}),
),
exclusive: (effect) =>
mutations.withPermit(
Effect.uninterruptibleMask((restore) =>
admission.close.pipe(
Effect.andThen(restore(idle.await).pipe(Effect.andThen(restore(effect)))),
Effect.ensuring(admission.open),
),
),
),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
-5
View File
@@ -4,7 +4,6 @@ import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { OperationError } from "@opencode-ai/schema/plugin"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -328,10 +327,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
plugin: {
list: () => response(plugin.list()),
// In-process management can wait on its own setup/tool lease. Use an external client instead.
check: () => Effect.fail(new OperationError({ message: "Check plugin updates through an external client." })),
update: () => Effect.fail(new OperationError({ message: "Update plugins through an external client." })),
reload: () => Effect.fail(new OperationError({ message: "Reload plugins through an external client." })),
},
reference: {
list: () => response(reference.list()),
+6 -15
View File
@@ -36,22 +36,15 @@ const Definition = Schema.Struct({
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
fresh?: Npm.EntryPoint,
) {
const npm = yield* Npm.Service
const resolved =
fresh ??
(path.isAbsolute(operation.target)
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined, generation: undefined }
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true }))
const entrypoint = resolved.entrypoint
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target =
typeof Bun !== "undefined" && path.isAbsolute(operation.target)
? operation.target.replaceAll("\\", "/")
: entrypoint
const source = fresh || operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
@@ -60,9 +53,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify([operation, resolved.generation]),
revision: resolved.revision,
generation: resolved.generation,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
@@ -1,7 +1,6 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import type { Plugin } from "@opencode-ai/schema/plugin"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -10,11 +9,6 @@ import type { Plugin } from "@opencode-ai/schema/plugin"
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
/** Initial reconciliation barrier; it never closes again after startup settles. */
readonly initialized: Effect.Effect<void>
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.OperationError>
readonly update: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
readonly reload: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+19 -203
View File
@@ -2,10 +2,8 @@ export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Option, Scope, Semaphore, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import path from "path"
import { randomUUID } from "node:crypto"
import { pathToFileURL } from "node:url"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -17,13 +15,10 @@ import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
type Cached = { readonly operation: string; readonly plugin: Plugin.Versioned; readonly error?: string }
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly ConfigPluginSource.Operation[],
cache: Map<string, Cached>,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -31,7 +26,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const previousSources = new Map(cache)
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
@@ -54,23 +48,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const cached = previousSources.get(operation.target)
const key = JSON.stringify(operation)
const plugin =
cached?.operation === key
? cached.plugin
: yield* PluginModule.load(operation).pipe(
Effect.flatMap((plugin) =>
cached && plugin.id !== cached.plugin.id
? Effect.fail(new Error(`Plugin ID changed from ${cached.plugin.id} to ${plugin.id}`))
: Effect.succeed(plugin),
),
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Plugin.errorMessage(cause) }),
),
),
)
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
@@ -78,24 +62,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
error: plugin.error,
tui: false,
})
if (cached) {
cache.set(operation.target, { ...cached, operation: key, error: plugin.error })
packages.set(operation.target, cached.plugin)
enabled.add(cached.plugin.id)
}
continue
}
if (cached?.operation === key && cached.error) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: cached.error,
tui: plugin.tui ?? false,
})
} else {
failures.delete(operation.target)
cache.set(operation.target, { operation: key, plugin })
}
failures.delete(operation.target)
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
@@ -108,12 +77,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.entries()]
.filter(([target]) => {
const plugin = packages.get(target)
return !plugin || enabled.has(plugin.id)
})
.map(([, failure]) => failure),
failures: [...failures.values()],
}
})
@@ -125,20 +89,12 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const scope = yield* Scope.Scope
const ready = yield* Latch.make()
const initialized = yield* Latch.make()
const mutations = yield* Semaphore.make(1)
const cache = new Map<string, Cached>()
// Internal definitions capture stable Location services; SDK contributions remain dynamic.
const internal = yield* PluginInternal.list()
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* (
operations: readonly ConfigPluginSource.Operation[],
previous: ReadonlyMap<string, Cached> = new Map(cache),
) {
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
@@ -152,117 +108,12 @@ export const layer = Layer.effect(
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations, cache).pipe(Effect.provideService(Npm.Service, npm))
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
const activated = yield* registry.activate(resolved.plugins, resolved.failures).pipe(Effect.exit)
if (activated._tag === "Failure") {
cache.clear()
previous.forEach((entry, target) => cache.set(target, entry))
return yield* Effect.failCause(activated.cause)
}
const inventory = yield* registry.list()
for (const [target, entry] of cache) {
if (!operations.some((operation) => operation.type === "add" && operation.target === target)) {
cache.delete(target)
continue
}
const active = inventory.find((item) =>
item.source.type === "local"
? item.source.path === target
: item.source.type === "package" && item.source.package === target,
)
if (active?.status !== "active" || !active.error) continue
const old = previous.get(target)
if (old) cache.set(target, { ...old, operation: entry.operation, error: active.error })
}
return inventory
yield* registry.activate(resolved.plugins, resolved.failures)
})
const operation = Effect.fnUntraced(function* (target: string) {
const operations = yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope))
const entry = operations.findLast((entry) => entry.type === "add" && entry.target === target)
const inventory = yield* registry.list()
if (
!entry ||
entry.type !== "add" ||
target === "*" ||
target.endsWith(".*") ||
target.startsWith("opencode.") ||
(!cache.has(target) &&
!inventory.some((item) =>
item.source.type === "local"
? item.source.path === target
: item.source.type === "package" && item.source.package === target,
))
)
return yield* new Plugin.OperationError({ message: `Not a configured plugin source: ${target}` })
return { entry, operations }
})
const mutate = (target: string, action: "update" | "reload") =>
initialized.await.pipe(
Effect.andThen(
mutations.withPermit(
Effect.gen(function* () {
const selected = yield* operation(target)
if (action === "update") {
if (path.isAbsolute(target) || !(yield* npm.inspect(target)).mutable)
return yield* new Plugin.OperationError({ message: `Plugin source cannot be updated: ${target}` })
}
const previous = new Map(cache)
const generation = randomUUID()
const staged = path.isAbsolute(target)
? {
directory: path.dirname(target),
generation,
entrypoint: `${typeof Bun !== "undefined" ? target.replaceAll("\\", "/") : pathToFileURL(target).href}?reload=${generation}`,
}
: yield* action === "update"
? npm.update(target, { subpaths: ["server", ""] })
: npm.reload(target, {
subpaths: ["server", ""],
revision: cache.get(target)?.plugin.revision,
generation: cache.get(target)?.plugin.generation,
})
const loaded = yield* PluginModule.load(selected.entry, staged).pipe(
Effect.provideService(Npm.Service, npm),
Effect.exit,
)
if (loaded._tag === "Failure") {
const old = cache.get(target)
if (!old) return yield* Effect.failCause(loaded.cause)
cache.set(target, {
...old,
operation: JSON.stringify(selected.entry),
error: Plugin.errorMessage(loaded.cause),
})
return yield* activate(selected.operations, previous)
}
const old = cache.get(target)
if (old && loaded.value.id !== old.plugin.id) {
cache.set(target, {
...old,
operation: JSON.stringify(selected.entry),
error: `Plugin ID changed from ${old.plugin.id} to ${loaded.value.id}`,
})
return yield* activate(selected.operations, previous)
}
cache.set(target, { operation: JSON.stringify(selected.entry), plugin: loaded.value })
return yield* activate(selected.operations, previous)
}),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
return Effect.fail(
error instanceof Plugin.OperationError
? error
: new Plugin.OperationError({ message: `Unable to ${action} plugin source.` }),
)
}),
)
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
@@ -279,48 +130,13 @@ export const layer = Layer.effect(
Stream.debounce("100 millis"),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* mutations
.withPermit(
Effect.gen(function* () {
return yield* activate(yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope)))
}),
)
.pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) {
yield* initialized.open
yield* ready.open
}
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) yield* ready.open
}),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({
flush: ready.await,
initialized: initialized.await,
check: (target) =>
initialized.await.pipe(
Effect.andThen(
mutations.withPermit(
Effect.gen(function* () {
yield* operation(target)
if (path.isAbsolute(target)) return { mutable: false }
return yield* npm.check(target)
}),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
return Effect.fail(
error instanceof Plugin.OperationError
? error
: new Plugin.OperationError({ message: "Unable to check plugin source." }),
)
}),
),
update: (target) => mutate(target, "update"),
reload: (target) => mutate(target, "reload"),
})
return Service.of({ flush: ready.await })
}),
)
+53 -42
View File
@@ -26,6 +26,7 @@ export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
materialized: Map<string, Info>
}
type Draft = {
@@ -47,61 +48,71 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "reference",
initial: () => ({ sources: new Map() }),
initial: () => ({ sources: new Map(), materialized: new Map() }),
draft: (draft) => ({
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
finalize: (draft) =>
Effect.gen(function* () {
materialized.clear()
for (const [name, source] of draft.list()) {
if (source.type === "local") {
materialized.set(
name,
Info.make({
name,
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
materialized.set(
prepare: (data) => {
for (const [name, source] of data.sources) {
if (source.type === "local") {
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
}
},
notify: () =>
Effect.gen(function* () {
for (const info of state.get().materialized.values()) {
const source = info.source
if (source.type !== "git") continue
yield* cache
.ensure({
reference: Repository.parseRemote(source.repository),
branch: source.branch,
refresh: true,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name: info.name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* bus.publish(Reference.Event.Updated, {})
}),
@@ -111,7 +122,7 @@ const layer = Layer.effect(
transform: state.transform,
reload: state.reload,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
return Array.from(state.get().materialized.values())
}),
})
}),
+86 -114
View File
@@ -41,7 +41,6 @@ import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { PluginExecution } from "./plugin/execution.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -691,39 +690,30 @@ const layer = Layer.effect(
),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const generate = yield* SessionGenerate.Service
return yield* gate.lease({ id: session.id, admission: true }, generate.generate(input))
}).pipe(Effect.provide(locations.get(session.location)))
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* generate.generate(input)
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* Effect.gen(function* () {
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const commands = yield* Command.Service
// Commands may await child Sessions, so they retain ownership that lets those children drain.
return yield* gate.lease(
session,
commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery: input.delivery ?? "steer",
},
}),
)
yield* plugins.flush
return yield* Command.Service
}).pipe(Effect.provide(locations.get(session.location)))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery,
},
})
}),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -733,20 +723,16 @@ const layer = Layer.effect(
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
yield* plugins.flush
const shell = yield* Shell.Service
return yield* gate.lease(
{ id: input.sessionID, admission: true },
shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie),
)
return yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
@@ -978,14 +964,10 @@ const layer = Layer.effect(
return yield* new BusyError({ sessionID: input.sessionID })
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: session.id, admission: true },
SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
),
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(locations.get(session.location)))
}),
@@ -994,12 +976,8 @@ const layer = Layer.effect(
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: session.id, admission: true },
SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus)),
)
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
}).pipe(Effect.provide(locations.get(session.location)))
yield* execution.wake(sessionID)
return revert
@@ -1041,65 +1019,59 @@ const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
messageID: SessionMessage.ID,
) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: request.sessionID, admission: true },
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
yield* plugins.flush
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
)
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
+1 -1
View File
@@ -125,7 +125,7 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
yield* plugins.initialized
yield* plugins.flush
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
+2 -11
View File
@@ -26,7 +26,6 @@ import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
import { PluginExecution } from "../../plugin/execution.js"
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
@@ -41,7 +40,6 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const pluginExecution = yield* PluginExecution.Service
const title = yield* SessionTitle.Service
const steps = yield* SessionStep.make
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
@@ -91,6 +89,7 @@ const layer = Layer.effect(
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
@@ -314,14 +313,7 @@ const layer = Layer.effect(
}
})
return Service.of({
drain: Effect.fnUntraced(function* (input) {
yield* plugins.initialized
const session = yield* store.get(input.sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${input.sessionID}`))
return yield* pluginExecution.lease(session, drain(input))
}),
})
return Service.of({ drain })
}),
)
@@ -340,6 +332,5 @@ export const node = makeLocationNode({
Snapshot.node,
ToolOutput.node,
Database.node,
PluginExecution.node,
],
})
+2 -8
View File
@@ -17,7 +17,6 @@ import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionUsage } from "./usage.js"
import { SessionStore } from "./store.js"
import { PluginExecution } from "../plugin/execution.js"
const MAX_LENGTH = 100
const MAX_CONTEXT_LENGTH = 8_000
@@ -166,14 +165,9 @@ export const layer = Layer.effect(
const context = yield* SessionContext.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const gate = yield* PluginExecution.Service
const title = make({ bus, llm, context, store })
return Service.of({
generate: Effect.fnUntraced(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return
return yield* gate.lease({ id: session.id, admission: true }, title.generate(database.db, sessionID))
}),
generate: (sessionID) => title.generate(database.db, sessionID),
})
}),
)
@@ -181,5 +175,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node, PluginExecution.node],
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],
})
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
draft.skills.delete(ID.make(id))
},
}),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
+90 -89
View File
@@ -1,6 +1,6 @@
export * as State from "./state.js"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
/**
* A replayable transform applied to a draft during reload.
@@ -16,13 +16,14 @@ export interface Registration {
}
/**
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
* Registers a scoped transform and invalidates the derived state. Closing the
* owning Scope removes the transform. Reads synchronously replay pending changes.
*/
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
/** Invalidates captured inputs immediately and coalesces change notifications. */
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
@@ -33,7 +34,7 @@ export interface Transformable<DraftApi> {
type Batch = {
active: boolean
readonly flush: boolean
readonly reloads: Set<Reload>
readonly notifications: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
@@ -41,17 +42,24 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
})
const reloadDebounce = 500
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* effect
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
batch.active = false
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
})
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* restore(effect)
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
batch.active = false
const notifications = batch.flush
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
: []
// Accepted writes are not rolled back: one failed observer must not hide
// the other states' changes, or replace the batch body's failure.
yield* Exit.asVoidAll([exit, ...notifications])
return yield* exit
}),
)
}
export const inherit = Effect.fnUntraced(function* () {
@@ -65,124 +73,117 @@ export interface Options<State, DraftApi> {
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/** Synchronously completes derived data after ordered transform replay. */
readonly prepare?: (state: State) => void
/**
* Runs after the rebuilt state becomes visible. Update events published here
* act as read barriers: subscribers refetching on the event observe the
* committed state.
* Observes accepted changes outside the read path. Batched writes notify at
* batch completion; reloads debounce notifications. Reads never run this hook.
* Resource reconciliation owns any coordination it requires.
*/
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
readonly notify?: () => Effect.Effect<void>
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
/** Returns the latest accepted state, replaying stale inputs synchronously. */
readonly get: () => State
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
let dirty = false
let requestedAt = 0
let running = false
let closed = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
let waiters: Deferred.Deferred<void>[] = []
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
if (options.finalize) yield* options.finalize(options.draft(next))
})
const materialize = Effect.fnUntraced(function* () {
if (closed) return
const get = () => {
if (!dirty || closed) return state
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
yield* commit(next)
transforms.forEach((transform) => transform.run(api))
options.prepare?.(next)
state = next
dirty = false
return state
}
const notify = Effect.fn("State.notify")(function* () {
if (closed) return
get()
if (options.notify) yield* options.notify()
})
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
const publish = (): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish()
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
// Release scheduling ownership before observers run: an observer may
// request and await another reload without joining this notification.
const completed = waiters
waiters = []
running = false
const exit = yield* notify().pipe(Effect.exit)
yield* Effect.forEach(completed, (done) => Deferred.done(done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
})
const reload = Effect.fnUntraced(function* () {
if (closed) return
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
yield* Deferred.await(done)
})
const changed = (debounce: boolean) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
if (debounce) dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (!batch.flush) {
closed = true
return
}
batch.notifications.add(notify)
return
}
if (!debounce) return yield* restore(notify())
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push(done)
if (!running) {
running = true
yield* publish().pipe(Effect.forkDetach)
}
yield* restore(Deferred.await(done))
}),
)
return {
get: () => state,
get,
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
const scope = yield* Scope.Scope
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { run: update }
let active = true
const dispose = Effect.uninterruptible(
semaphore.withPermit(
Effect.suspend(() => {
if (!active) return Effect.void
active = false
transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
// Detached debounced reloads must also stay quiet after teardown.
if (!batch.flush) {
closed = true
return
}
batch.reloads.add(materializeReload)
return
}
yield* materialize()
})
}),
),
)
yield* semaphore.withPermit(
Effect.sync(() => {
transforms = [...transforms, transform]
Effect.suspend(() => {
if (!transforms.delete(transform)) return Effect.void
dirty = true
return changed(false)
}),
)
transforms.add(transform)
dirty = true
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
yield* changed(false)
return { dispose }
}),
)
}),
reload,
reload: () => changed(true),
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ const layer = Layer.effect(
draft.tools.delete(id)
},
}),
finalize: () =>
notify: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
+20 -9
View File
@@ -1,7 +1,7 @@
export * as Vcs from "./vcs.js"
import path from "path"
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Context, Effect, Layer, Schema, Semaphore, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
@@ -49,6 +49,7 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const vcs = location.vcs
const current: { info: Info } = { info: { branch: {} } }
const refreshLock = Semaphore.makeUnsafe(1)
const scope = {
directory: location.directory,
worktree: location.project.directory,
@@ -69,7 +70,7 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => refresh(),
notify: () => refresh(),
})
const selected = () => {
const value = state.get()
@@ -87,13 +88,23 @@ const layer = Layer.effect(
),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
const changed = yield* Effect.gen(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
return changed
}).pipe(refreshLock.withPermit)
if (!changed) return
// Legacy listeners can publish nested updates before streams and SSE receive
// this event. Re-announce the latest branch if publication was overtaken.
while (true) {
const branch = current.info.branch.current
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
if (branch === current.info.branch.current) return
}
})
if (vcs) {
+1 -1
View File
@@ -88,7 +88,7 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
+3 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Ref, Schema, Stream } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Session } from "@opencode-ai/schema/session"
@@ -228,10 +228,10 @@ describe("Bus", () => {
)
const event = yield* bus.publish(SyncMessage, { id: "one", text: "hello" })
yield* bus.publish(SyncMessage, { id: "one", text: "second event" })
yield* bus.publish(SyncMessage, { id: "one", text: "after unsubscribe" })
expect(received[0]).toEqual(event)
expect(received[1]?.data).toEqual({ id: "one", text: "second event" })
expect(received[1]?.data).toEqual({ id: "one", text: "after unsubscribe" })
}),
)
+53
View File
@@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -30,6 +31,57 @@ const catalogLayer = AppNodeBuilder.build(
const it = testEffect(catalogLayer)
describe("Catalog", () => {
it.effect("reads available and default models inside a batch before publishing", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const observed: string[] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Catalog.Event.Updated.type
? catalog.model.default().pipe(
Effect.map((model) => {
observed.push(model?.id ?? "none")
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const providerID = Provider.ID.make("test")
const old = Model.ID.make("old")
const newest = Model.ID.make("new")
yield* State.batch(
Effect.gen(function* () {
yield* catalog.transform((draft) => {
draft.provider.update(providerID, () => {})
draft.model.update(providerID, old, (model) => {
model.time.released = 1000
})
draft.model.update(providerID, newest, (model) => {
model.time.released = 2000
})
draft.model.default.set(providerID, old)
})
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest, old])
expect((yield* catalog.model.default())?.id).toBe(old)
const overlay = yield* catalog.transform((draft) =>
draft.model.update(providerID, old, (model) => {
model.enabled = false
}),
)
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest])
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* overlay.dispose
expect((yield* catalog.model.default())?.id).toBe(old)
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([old])
}),
)
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -291,6 +343,7 @@ describe("Catalog", () => {
configured = false
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
+1
View File
@@ -13,6 +13,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { Global } from "@opencode-ai/util/global"
+1 -106
View File
@@ -19,15 +19,13 @@ import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { git } from "../fixture/git"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Npm.node]), [
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
@@ -39,85 +37,6 @@ const staticIt = testEffect(
)
describe("PluginSupervisor config", () => {
it.live(
"updates a real package graph and retains its active revision after failed import and setup",
() =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const location = yield* Location.Service
const registry = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
const agents = yield* Agent.Service
const npm = yield* Npm.Service
const repository = path.join(location.directory, "package")
const target = `git+${pathToFileURL(repository).href}#fixture-branch`
const initial = (yield* registry.list())[0]
expect(initial?.revision).toMatch(/^[0-9a-f]{40}$/)
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("first")
yield* Effect.promise(() => fs.writeFile(path.join(repository, "value.js"), `export const value = "second"`))
yield* commitPackage(repository)
const check = yield* supervisor.check(target)
expect(check.mutable).toBe(true)
expect(check.installed).toBe(initial?.revision)
expect(check.available).not.toBe(initial?.revision)
const updated = (yield* supervisor.update(target))[0]
expect(updated?.revision).toBe(check.available)
expect(updated?.generation).not.toBe(initial?.generation)
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
yield* Effect.promise(() => fs.writeFile(path.join(repository, "index.js"), packagePlugin(true)))
yield* commitPackage(repository)
const failedSetup = (yield* supervisor.update(target))[0]
expect(failedSetup).toEqual({ ...updated, error: expect.stringContaining("package setup failed") })
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
expect((yield* npm.inspect(target)).installed).not.toBe(updated?.revision)
yield* Effect.promise(() =>
fs.writeFile(path.join(repository, "index.js"), `import "./missing.js"; ${packagePlugin()}`),
)
yield* commitPackage(repository)
const failedImport = (yield* supervisor.update(target))[0]
expect(failedImport).toEqual({ ...updated, error: expect.stringContaining("missing.js") })
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
const reloaded = (yield* supervisor.reload(target))[0]
expect(reloaded?.status).toBe("active")
expect(reloaded?.revision).toBe(updated?.revision)
expect(reloaded?.error).toBeUndefined()
expect(reloaded?.generation).not.toBe(updated?.generation)
}),
false,
async (directory) => {
const repository = path.join(directory, "package")
await fs.mkdir(repository)
await fs.writeFile(
path.join(repository, "package.json"),
JSON.stringify({
name: "fixture-core-plugin",
version: "1.0.0",
type: "module",
exports: "./index.js",
}),
)
await fs.writeFile(path.join(repository, "index.js"), packagePlugin())
await fs.writeFile(path.join(repository, "value.js"), `export const value = "first"`)
await git(repository, "init", "-q", "-b", "fixture-branch")
await Effect.runPromise(commitPackage(repository))
await fs.writeFile(
path.join(directory, "opencode.json"),
JSON.stringify({
plugins: [
"-*",
{ package: `git+${pathToFileURL(repository).href}#fixture-branch`, options: { selection: "first" } },
{ package: `git+${pathToFileURL(repository).href}#fixture-branch`, options: { selection: "last" } },
],
}),
)
},
),
30_000,
)
it.live("applies selectors in order", () =>
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
@@ -586,30 +505,6 @@ export default Plugin.define({
`
}
function packagePlugin(fail = false) {
return `import { value } from "./value.js"
export default { id: "package-plugin", tui: true, async setup(ctx) {
await ctx.agent.transform((draft) => draft.update("package-agent", (agent) => { agent.description = value }))
${fail ? 'throw new Error("package setup failed")' : ""}
} }`
}
const commitPackage = (repository: string) =>
Effect.promise(async () => {
await git(repository, "add", ".")
await git(
repository,
"-c",
"user.name=fixture",
"-c",
"user.email=fixture@example.com",
"commit",
"-q",
"-m",
"fixture",
)
})
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq, sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Effect, Tracer } from "effect"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { isSqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
@@ -16,7 +16,7 @@ const users = sqliteTable("users", {
name: text().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
)
@@ -2,7 +2,8 @@ import { describe, expect } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Effect, Exit, PlatformError, Stream } from "effect"
import { Effect, Exit, Stream } from "effect"
import type * as PlatformError from "effect/PlatformError"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Logger } from "effect"
import { Effect, Layer, Logger } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
@@ -0,0 +1,127 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "../lib/effect"
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
describe("LocationWatcherPolicy", () => {
it.effect("reads batched registrations and disposals without notifying observers", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* State.batch(
Effect.gen(function* () {
yield* policy.transform((draft) => draft.add(["base"]))
const overlay = yield* policy.transform((draft) => draft.add(["overlay"]))
const snapshot = policy.current()
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
yield* overlay.dispose
expect(policy.current()).toEqual(["base"])
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["base"]])
}),
)
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(ignore))
const snapshot = policy.current()
observed.length = 0
ignore = ["second"]
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(snapshot).toEqual(["first"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
}),
)
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
yield* policy.transform((draft) => draft.add(["inner"])).pipe(Scope.provide(scope))
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(["outer"]))
expect(policy.current()).toEqual(["outer", "inner"])
expect(observed).toEqual([
["outer", "inner"],
["outer", "inner"],
])
}),
)
it.effect("allows an observer to await a reload and keeps later observers current", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
ignore = ["second"]
yield* policy.reload()
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
const writer = yield* policy
.transform((draft) => draft.add(ignore))
.pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(writer)
expect(observed).toEqual([["second"], ["second"]])
}),
)
})
@@ -22,7 +22,6 @@ import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
import { noopPluginSupervisor } from "../fixture/plugin-supervisor"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
@@ -32,7 +31,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
deps: [],
})
@@ -315,7 +314,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return noopPluginSupervisor()
return PluginSupervisor.Service.of({ flush: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
+1 -1
View File
@@ -1,4 +1,4 @@
import { Effect, Layer } from "effect"
import { Effect, Layer, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { Mcp } from "@opencode-ai/core/mcp/index"
@@ -1,11 +0,0 @@
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
export const noopPluginSupervisor = (flush: Effect.Effect<unknown> = Effect.void) =>
PluginSupervisor.Service.of({
flush: Effect.asVoid(flush),
initialized: Effect.asVoid(flush),
check: () => Effect.die("unused plugin.check"),
update: () => Effect.die("unused plugin.update"),
reload: () => Effect.die("unused plugin.reload"),
})
@@ -2,20 +2,17 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./plugin-supervisor"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Layer, LayerMap } from "effect"
import { Effect, Layer, LayerMap } from "effect"
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
export const promptLocationLayer = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.mergeAll(
Layer.merge(
LayerNode.compile(PluginHooks.node),
PluginExecution.layer,
Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
) as Layer.Layer<LocationServices>,
),
)
+2 -2
View File
@@ -1,10 +1,10 @@
import fs from "fs/promises"
import os from "os"
import { tmpdir as osTmpdir } from "os"
import path from "path"
import { Effect } from "effect"
export const tmpdir = async (prefix = "opencode-core-test-") => {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), prefix)))
return {
path: dir,
async [Symbol.asyncDispose]() {
@@ -7,6 +7,7 @@ import { Bus } from "@opencode-ai/core/bus"
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import os from "os"
import { Effect, Layer } from "effect"
import { TestClock } from "effect/testing"
import * as TestClock from "effect/testing/TestClock"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -0,0 +1,73 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Integration } from "@opencode-ai/core/integration"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node])))
describe("Integration replay", () => {
it.effect("fails and closes an OAuth attempt when fresh implementation replay throws", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("replay-test")
const methodID = Integration.MethodID.make("code")
const source = { fail: false, closed: false }
const failure = new Error("integration transform replay failed")
yield* integrations.transform((editor) => {
if (source.fail) throw failure
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Fixture" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (source.closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Enter the fixture code",
callback: () =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "dummy-access",
refresh: "dummy-refresh",
expires: Number.MAX_SAFE_INTEGER,
}),
),
}),
),
})
})
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Fixture" })
source.fail = true
const reload = yield* integrations.reload().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
source.fail = false
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
}),
)
const exit = yield* integrations.oauth
.complete({ integrationID, attemptID: attempt.attemptID, code: "dummy-code" })
.pipe(Effect.exit)
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
status: "failed",
message: failure.message,
time: attempt.time,
})
expect(source.closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
})
+98 -1
View File
@@ -1,12 +1,13 @@
import { describe, expect } from "bun:test"
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import * as TestClock from "effect/testing/TestClock"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -262,6 +263,102 @@ describe("Integration", () => {
}),
)
it.effect("resolves stored OAuth with refresh registrations made inside a batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const method = Integration.OAuthMethod.make({
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "Browser",
})
const expired = Credential.OAuth.make({
type: "oauth",
methodID: method.id,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const connection = { type: "credential" as const, id: stored.id, label: stored.label }
const calls: string[] = []
const implementation = {
integrationID,
method,
authorize: () => Effect.die("unexpected authorization"),
refresh: (value: Credential.OAuth) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("original")
return fresh
}),
}
yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) => editor.method.update(implementation))
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(calls).toEqual(["original"])
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original"])
yield* credentials.update(stored.id, { value: expired })
const overridden = Credential.OAuth.make({ ...fresh, access: "override" })
const override = yield* integrations.transform((editor) =>
editor.method.update({
...implementation,
refresh: (value) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("override")
return overridden
}),
}),
)
expect(yield* integrations.connection.resolve(connection)).toEqual(overridden)
expect((yield* credentials.get(stored.id))?.value).toEqual(overridden)
yield* override.dispose
yield* credentials.update(stored.id, { value: expired })
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original", "override", "original"])
yield* credentials.update(stored.id, { value: expired })
const removal = yield* integrations.transform((editor) => editor.method.remove(integrationID, method))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original"])
yield* removal.dispose
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
yield* credentials.update(stored.id, { value: expired })
yield* integrations.transform((editor) => editor.method.update({ ...implementation, refresh: undefined }))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original", "original"])
const failure = new Error("refresh failed")
yield* integrations.transform((editor) =>
editor.method.update({ ...implementation, refresh: () => Effect.fail(failure) }),
)
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
new Integration.AuthorizationError({ cause: failure }),
)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
}),
)
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+3 -2
View File
@@ -1,6 +1,7 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer, type Scope } from "effect"
import { TestClock, TestConsole } from "effect/testing"
import { Cause, Effect, Exit, Layer, Scope } from "effect"
import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
+2 -2
View File
@@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import type { SessionError } from "@opencode-ai/schema/session-error"
import { Tool } from "@opencode-ai/core/tool"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, type Scope } from "effect"
import { host } from "../plugin/host"
@@ -52,7 +52,7 @@ export function waitForCodeModeTool(
export const registerToolPlugin = <R>(
plugin: {
readonly id: string
readonly effect: (context: Context) => Effect.Effect<void, never, R>
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
},
overrides: Parameters<typeof host>[0] = {},
): Effect.Effect<void, never, R | Tool.Service | Scope.Scope> =>
+11 -12
View File
@@ -304,7 +304,7 @@ describe("LocationServiceMap", () => {
),
)
itWithSdk.live("reconciles Config updates during startup without restarting unchanged plugins", () =>
itWithSdk.live("reruns activation for Config updates during startup", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -316,14 +316,18 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() => fs.writeFile(file, "{}"))
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "blocked-config-reload",
effect: () =>
Effect.sync(() => ++activations.count).pipe(
Effect.andThen(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
Effect.flatMap((activation) =>
activation === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
),
),
}),
@@ -351,17 +355,12 @@ describe("LocationServiceMap", () => {
Effect.provide(context),
Effect.forkChild,
)
const initialFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.initialized).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
expect(initialFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(flushFiber)
yield* Fiber.join(initialFiber)
expect(activations.count).toBe(1)
const inventory = yield* Plugin.Service.use((plugins) => plugins.list()).pipe(Effect.provide(context))
expect(inventory.map((plugin) => plugin.id)).toContain(Plugin.ID.make("config-effect-plugin"))
expect(activations.count).toBe(2)
}),
),
),
+134 -3
View File
@@ -33,9 +33,25 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { State } from "@opencode-ai/core/state"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import {
Context,
DateTime,
Deferred,
Effect,
Exit,
Fiber,
Layer,
PubSub,
Ref,
Schedule,
Schema,
Scope,
Sink,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
@@ -66,7 +82,7 @@ function resourceServer(
listChanged?: boolean
emptyElicitation?: boolean
urlElicitation?: boolean
respond?: (request: Request) => Response | undefined
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
} = {},
) {
return Effect.acquireRelease(
@@ -158,7 +174,7 @@ function resourceServer(
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
state.initializations += 1
}
return input.respond?.(request) ?? transport.handleRequest(request)
return (await input.respond?.(request)) ?? transport.handleRequest(request)
},
})
return {
@@ -1325,6 +1341,121 @@ test("reconciles only changed MCP server config", async () => {
)
})
testEffect(Layer.empty).live("serializes MCP config restoration behind an in-flight replacement", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const accepted = yield* Deferred.make<void>()
const server = yield* resourceServer({
respond: (request) =>
request.method !== "POST"
? undefined
: Effect.runPromise(
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
),
})
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
const replacing = yield* service
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
const restoring = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(accepted)
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(replacing)
yield* Fiber.join(restoring)
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(yield* service.tools()).toEqual([])
expect(server.state.initializations).toBe(1)
}).pipe(
Effect.ensuring(Deferred.succeed(release, undefined)),
Effect.provide(
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
),
)
}),
)
testEffect(
AppNodeBuilder.build(
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
[
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
),
],
[Environment.node, hostEnvironmentLayer],
],
),
).effect("discards queued MCP notifications after its layer closes", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
Effect.andThen(TestClock.adjust("500 millis")),
),
)
const context = yield* Layer.build(Mcp.layer()).pipe(Scope.provide(root))
const service = Context.get(context, Mcp.Service)
const observed: string[] = []
let block = false
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== McpEvent.StatusChanged.type) return
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const source = { url: "https://example.com/initial", added: false }
yield* service
.transform((draft) => {
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
})
.pipe(Scope.provide(root))
block = true
source.url = "https://example.com/first"
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
source.url = "https://example.com/second"
source.added = true
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* State.batch(Scope.close(root, Exit.void), { flush: false })
observed.length = 0
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(observed).toEqual([])
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
}),
)
test("serializes concurrent MCP lifecycle operations", async () => {
await Effect.runPromise(
Effect.scoped(
+7 -576
View File
@@ -2,12 +2,11 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { tmpdir, withTempDir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32"
@@ -28,27 +27,12 @@ async function createGitFixture(directory: string) {
await fs.mkdir(path.join(repository, "dependency"), { recursive: true })
await writePackage(repository, {
name: "fixture-git-plugin",
type: "module",
exports: { ".": "./index.js", "./cjs": "./index.cjs" },
exports: "./index.js",
dependencies: { "fixture-dependency": "file:./dependency" },
})
await writePackage(path.join(repository, "dependency"), {
name: "fixture-dependency",
type: "module",
exports: { import: "./index.js", require: "./index.cjs" },
})
await Bun.write(
path.join(repository, "index.js"),
'import { dependency } from "fixture-dependency"\nimport { relative } from "./relative.js"\nexport default { root: true }\nexport { dependency, relative }\n',
)
await Bun.write(path.join(repository, "relative.js"), 'export const relative = { value: "first" }\n')
await Bun.write(path.join(repository, "dependency", "index.js"), 'export const dependency = { value: "first" }\n')
await Bun.write(
path.join(repository, "index.cjs"),
'module.exports = { relative: require("./relative.cjs"), dependency: require("fixture-dependency") }\n',
)
await Bun.write(path.join(repository, "relative.cjs"), 'module.exports = { value: "first" }\n')
await Bun.write(path.join(repository, "dependency", "index.cjs"), 'module.exports = { value: "first" }\n')
await writePackage(path.join(repository, "dependency"), { name: "fixture-dependency", exports: "./index.js" })
await Bun.write(path.join(repository, "index.js"), "export default { root: true }\n")
await Bun.write(path.join(repository, "dependency", "index.js"), "export const dependency = true\n")
const subdirectory = path.join(repository, "packages", "subdirectory-plugin")
await fs.mkdir(path.join(subdirectory, "dependency"), { recursive: true })
@@ -71,123 +55,6 @@ async function createGitFixture(directory: string) {
return { repository, commit }
}
async function advanceGitFixture(repository: string, value: string) {
await Bun.write(path.join(repository, "relative.js"), `export const relative = { value: ${JSON.stringify(value)} }\n`)
await Bun.write(
path.join(repository, "dependency", "index.js"),
`export const dependency = { value: ${JSON.stringify(value)} }\n`,
)
await Bun.write(path.join(repository, "relative.cjs"), `module.exports = { value: ${JSON.stringify(value)} }\n`)
await Bun.write(
path.join(repository, "dependency", "index.cjs"),
`module.exports = { value: ${JSON.stringify(value)} }\n`,
)
await Bun.$`git -C ${repository} add .`
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm ${value}`
return Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
}
async function createRegistryFixture(directory: string) {
const tarballs = new Map<string, Uint8Array>()
for (const version of ["1.0.0", "1.1.0", "1.2.0"]) {
const root = path.join(directory, version)
const pkg = path.join(root, "package")
await fs.mkdir(pkg, { recursive: true })
await writePackage(pkg, {
name: "@fixture/registry-plugin",
version,
type: "module",
exports: { ".": "./index.js", "./tui": "./index.js" },
dependencies: { "@fixture/registry-dependency": "^1.0.0" },
})
await Bun.write(
path.join(pkg, "index.js"),
'export { relative } from "./relative.js"\nexport { dependency } from "@fixture/registry-dependency"\n',
)
await Bun.write(path.join(pkg, "relative.js"), `export const relative = { value: ${JSON.stringify(version)} }\n`)
await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
for (const version of ["1.0.0", "1.1.0"]) {
const root = path.join(directory, `dependency-${version}`)
const pkg = path.join(root, "package")
await fs.mkdir(pkg, { recursive: true })
await writePackage(pkg, { name: "@fixture/registry-dependency", version, type: "module", exports: "./index.js" })
await Bun.write(path.join(pkg, "index.js"), `export const dependency = { value: ${JSON.stringify(version)} }\n`)
await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(`dependency-${version}`, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = {
latest: "1.0.0",
dependency: "1.0.0",
requests: 0,
offline: false,
beforeTarball: () => Promise.resolve(),
}
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
state.requests++
if (state.offline) return new Response("offline", { status: 503 })
if (request.headers.get("authorization") !== "Bearer fixture-only-token")
return new Response("unauthorized", { status: 401 })
const url = new URL(request.url)
if (decodeURIComponent(url.pathname) === "/@fixture/registry-dependency")
return Response.json({
name: "@fixture/registry-dependency",
"dist-tags": { latest: state.dependency },
versions: Object.fromEntries(
["1.0.0", ...(state.dependency === "1.1.0" ? ["1.1.0"] : [])].map((version) => [
version,
{
name: "@fixture/registry-dependency",
version,
dist: { tarball: `${url.origin}/tarball/dependency-${version}.tgz` },
},
]),
),
})
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
["1.0.0", "1.1.0", "1.2.0", "1.3.0"].map((version) => [
version,
{
name: "@fixture/registry-plugin",
version,
dependencies: { "@fixture/registry-dependency": "^1.0.0" },
dist: { tarball: `${url.origin}/tarball/${version}.tgz` },
},
]),
),
})
const tarball = tarballs.get(url.pathname.replace("/tarball/", "").replace(".tgz", ""))
if (tarball) await state.beforeTarball()
return tarball ? new Response(tarball) : new Response("missing tarball", { status: 404 })
},
})
return {
state,
async configure(cache: string, spec: string) {
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`registry=${server.url}\n@fixture:registry=${server.url}\n//${server.url.host}/:_authToken=fixture-only-token\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
const it = testEffect(Layer.empty)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -336,10 +203,7 @@ describe("Npm.add", () => {
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(
path.join("packages", await Npm.cacheKey(spec), "generations", entries.added.generation, "node_modules"),
)
expect(entries.added.revision).toBe(fixture.commit)
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
}
})
@@ -357,15 +221,6 @@ describe("Npm.add", () => {
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
await advanceGitFixture(fixture.repository, "second")
const exact = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(`git+file://${fixture.repository}#fixture-branch::path:packages/subdirectory-plugin`, {
revision: fixture.commit,
})
}).pipe(Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(exact.revision).toBe(fixture.commit)
expect(exact.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
})
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
@@ -438,427 +293,3 @@ describe("Npm.resolve", () => {
expect(resolved.entrypoint).toEndWith("/tui.js")
})
})
describe("Npm generations", () => {
it.live(
"loads the cached immutable graph while an updater holds the install lock",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec, { refresh: true })
fixture.state.latest = "1.1.0"
fixture.state.dependency = "1.1.0"
const started = Promise.withResolvers<void>()
const resume = Promise.withResolvers<void>()
fixture.state.beforeTarball = () => {
started.resolve()
return resume.promise
}
const updating = yield* npm.update(spec).pipe(Effect.forkScoped)
yield* Effect.gen(function* () {
yield* Effect.promise(() => started.promise).pipe(Effect.timeout("5 seconds"))
const adding = yield* Effect.all([
npm.add(spec),
npm.add(spec, { revision: first.revision, subpaths: ["tui"] }),
npm.add(spec, { refresh: true }),
]).pipe(Effect.forkScoped)
// Timeout the join, not uninterruptible lock acquisition; release the updater before scoped cleanup.
expect(yield* Fiber.join(adding).pipe(Effect.timeout("5 seconds"))).toEqual([first, first, first])
expect(updating.pollUnsafe()).toBeUndefined()
}).pipe(Effect.ensuring(Effect.sync(() => resume.resolve())))
const updated = yield* Fiber.join(updating)
expect(updated.revision).toBe("1.1.0")
expect(updated.generation).not.toBe(first.generation)
expect(yield* npm.add(spec)).toEqual(updated)
const retained = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(retained.relative.value).toBe("1.0.0")
expect(retained.dependency.value).toBe("1.0.0")
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"reloads the retained local generation when the same npm revision acquired a different dependency graph",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.promise(() => fixture.configure(cache, pinned))
const entries = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec)
const loaded = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(loaded.dependency.value).toBe("1.0.0")
fixture.state.dependency = "1.1.0"
const second = yield* npm.update(spec)
expect(second.revision).toBe(first.revision)
expect(second.generation).not.toBe(first.generation)
expect((yield* Effect.promise(() => import(second.entrypoint ?? ""))).dependency.value).toBe("1.1.0")
expect(yield* npm.resolve(spec, { revision: first.revision })).toEqual(second)
const foreign = yield* npm.add(pinned)
return { first, second, foreign, loaded }
}).pipe(Effect.provide(npmLayer(cache)))
fixture.state.offline = true
const requests = fixture.state.requests
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(entries.second)
expect((yield* npm.reload(spec, { generation: "../../outside" }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect((yield* npm.reload(spec, { generation: entries.foreign.generation }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect(
(yield* npm.reload(spec, { generation: entries.first.generation, revision: "1.1.0" }).pipe(Effect.flip))
._tag,
).toBe("NpmInstallFailedError")
expect(yield* npm.resolve(spec)).toEqual(entries.second)
const reloaded = yield* npm.reload(spec, {
generation: entries.first.generation,
revision: entries.first.revision,
})
expect(reloaded.revision).toBe(entries.first.revision)
expect(reloaded.generation).not.toBe(entries.first.generation)
const fresh = yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))
expect(fresh.dependency.value).toBe("1.0.0")
expect(fresh.dependency).not.toBe(entries.loaded.dependency)
expect(yield* npm.resolve(spec)).toEqual(reloaded)
expect(fixture.state.requests).toBe(requests)
expect(
yield* Effect.promise(() =>
Bun.file(path.join(root, "generations", entries.first.generation, "generation.json")).json(),
),
).toEqual({ name: "@fixture/registry-plugin", generation: entries.first.generation, revision: "1.0.0" })
expect(
yield* Effect.promise(() =>
Bun.file(path.join(path.dirname(entries.second.directory), "registry-dependency", "index.js")).text(),
),
).toContain('"1.1.0"')
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"defers cancellation through publication without removing the committed generation",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec)
fixture.state.latest = "1.1.0"
const started = Promise.withResolvers<void>()
const resume = Promise.withResolvers<void>()
fixture.state.beforeTarball = () => {
started.resolve()
return resume.promise
}
const updating = yield* npm.update(spec).pipe(Effect.forkScoped)
yield* Effect.promise(() => started.promise)
updating.interruptUnsafe()
expect(updating.pollUnsafe()).toBeUndefined()
resume.resolve()
const exit = yield* Fiber.await(updating)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
// The pending interrupt is observed after publication, outside staging's cleanup boundary.
const published = yield* npm.resolve(spec)
expect(published.revision).toBe("1.1.0")
expect(published.generation).not.toBe(first.generation)
expect((yield* Effect.promise(() => import(published.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"1.0.0"',
)
expect((yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).length).toBe(2)
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"finds binaries in published generations and keeps their old files usable after reload",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = path.join(tmp.path, "binary")
yield* Effect.promise(async () => {
await fs.mkdir(fixture)
await writePackage(fixture, {
name: "fixture-bin",
bin: { "fixture-bin": "./cli.js", "fixture-other": "./other.js" },
})
await Bun.write(path.join(fixture, "cli.js"), '#!/usr/bin/env node\nconsole.log(require("./value.cjs"))\n')
await Bun.write(path.join(fixture, "value.cjs"), 'module.exports = "fixture"\n')
await Bun.write(path.join(fixture, "other.js"), '#!/usr/bin/env node\nconsole.log("other")\n')
})
const spec = `fixture-bin@file:${fixture}`
const cache = path.join(tmp.path, "cache")
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const binary = yield* npm.which(spec)
expect(binary).toEndWith(path.join(".bin", "fixture-bin"))
expect(yield* npm.which(spec, "fixture-other")).toEndWith(path.join(".bin", "fixture-other"))
expect(yield* npm.which(spec, "missing")).toBeUndefined()
const original = yield* npm.resolve(spec)
const reloaded = yield* npm.reload(spec)
expect(reloaded.generation).not.toBe(original.generation)
const fresh = yield* npm.which(spec)
expect(fresh).not.toBe(binary)
expect(fresh).toContain(reloaded.generation)
expect(yield* Effect.promise(() => Bun.file(binary ?? "").text())).toContain("value.cjs")
expect(yield* Effect.promise(() => Bun.file(fresh ?? "").text())).toContain("value.cjs")
if (!win)
yield* Effect.promise(async () => {
const child = Bun.spawn([process.execPath, fresh ?? ""], { stdout: "pipe", stderr: "pipe" })
expect(await child.exited, await new Response(child.stderr).text()).toBe(0)
expect(await new Response(child.stdout).text()).toBe("fixture\n")
})
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
)
it.live(
"checks without changing files, updates repeatedly, preserves pins, and reloads the whole Git graph offline",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.promise(() => createGitFixture(tmp.path))
const cache = path.join(tmp.path, "cache")
const spec = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
const pinned = `git+${pathToFileURL(fixture.repository).href}#${fixture.commit}`
const entries = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec, { refresh: true })
const pin = yield* npm.add(pinned)
const firstModule = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(firstModule.relative.value).toBe("first")
expect(firstModule.dependency.value).toBe("first")
const secondCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "second"))
expect(yield* npm.check(spec)).toEqual({ installed: fixture.commit, available: secondCommit, mutable: true })
expect(yield* npm.resolve(spec)).toEqual(first)
expect(yield* npm.add(spec, { refresh: true })).toEqual(first)
const second = yield* npm.update(spec)
expect(second.revision).toBe(secondCommit)
const secondModule = yield* Effect.promise(() => import(second.entrypoint ?? ""))
expect(secondModule.relative.value).toBe("second")
expect(secondModule.dependency.value).toBe("second")
expect(second.generation).not.toBe(first.generation)
const thirdCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "third"))
const third = yield* npm.update(spec)
expect(third.revision).toBe(thirdCommit)
const thirdModule = yield* Effect.promise(() => import(third.entrypoint ?? ""))
expect(thirdModule.relative.value).toBe("third")
expect(thirdModule.dependency.value).toBe("third")
const thirdCjs = yield* npm.resolve(spec, { subpaths: ["cjs"] })
const thirdCommonJs = yield* Effect.promise(() => import(thirdCjs.entrypoint ?? ""))
expect(thirdCommonJs.default.relative.value).toBe("third")
expect(thirdCommonJs.default.dependency.value).toBe("third")
expect(yield* npm.check(pinned)).toEqual({
installed: fixture.commit,
available: fixture.commit,
mutable: false,
})
expect(yield* npm.update(pinned)).toEqual(pin)
const companionCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "companion"))
const companion = yield* npm.add(spec, { revision: companionCommit })
expect(companion.revision).toBe(companionCommit)
expect((yield* Effect.promise(() => import(companion.entrypoint ?? ""))).dependency.value).toBe("companion")
expect(yield* npm.resolve(spec)).toEqual(third)
expect(yield* npm.resolve(spec, { revision: companionCommit })).toEqual(companion)
yield* Effect.promise(() => fs.rename(fixture.repository, `${fixture.repository}-offline`))
const root = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(spec)))
const before = yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))
expect((yield* npm.update(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect(yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).toEqual(before)
expect(yield* npm.resolve(spec)).toEqual(third)
const reloaded = yield* npm.reload(spec)
expect(reloaded.revision).toBe(thirdCommit)
expect(reloaded.generation).not.toBe(third.generation)
const reloadedModule = yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))
expect(reloadedModule.relative).toEqual(thirdModule.relative)
expect(reloadedModule.relative).not.toBe(thirdModule.relative)
expect(reloadedModule.dependency).not.toBe(thirdModule.dependency)
const freshCjs = yield* npm.resolve(spec, { subpaths: ["cjs"] })
const freshCommonJs = yield* Effect.promise(() => import(freshCjs.entrypoint ?? ""))
expect(freshCommonJs.default.relative).not.toBe(thirdCommonJs.default.relative)
expect(freshCommonJs.default.dependency).not.toBe(thirdCommonJs.default.dependency)
const exactReload = yield* npm.reload(spec, { revision: companionCommit })
expect(exactReload.revision).toBe(companionCommit)
expect(yield* npm.resolve(spec)).toEqual(reloaded)
expect(yield* npm.update(pinned)).toEqual(pin)
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"first"',
)
expect(yield* Effect.promise(() => Bun.file(path.join(second.directory, "relative.js")).text())).toContain(
'"second"',
)
return { first, third, reloaded, exactReload, thirdCjs, freshCjs }
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(entries.reloaded)
expect(yield* npm.inspect(spec)).toEqual({ installed: entries.third.revision, mutable: true })
expect(yield* npm.resolve(spec, { revision: entries.exactReload.revision })).toEqual(entries.exactReload)
}).pipe(Effect.provide(npmLayer(cache)))
// Exercise the same realpaths in Node's ESM loader as well as Bun's loader above.
const node = Bun.which("node")
if (node)
yield* Effect.promise(async () => {
const script = `
import assert from "node:assert/strict";
const first = await import(${JSON.stringify(entries.first.entrypoint)});
const third = await import(${JSON.stringify(entries.third.entrypoint)});
const fresh = await import(${JSON.stringify(entries.reloaded.entrypoint)});
assert.equal(first.relative.value, "first");
assert.equal(third.relative.value, "third");
assert.equal(third.dependency.value, "third");
assert.notEqual(fresh.relative, third.relative);
assert.notEqual(fresh.dependency, third.dependency);
const thirdCjs = await import(${JSON.stringify(entries.thirdCjs.entrypoint)});
const freshCjs = await import(${JSON.stringify(entries.freshCjs.entrypoint)});
assert.equal(thirdCjs.default.relative.value, "third");
assert.equal(thirdCjs.default.dependency.value, "third");
assert.notEqual(freshCjs.default.relative, thirdCjs.default.relative);
assert.notEqual(freshCjs.default.dependency, thirdCjs.default.dependency);
`
const child = Bun.spawn([node, "--input-type=module", "-e", script], { stdout: "pipe", stderr: "pipe" })
expect(await child.exited, await new Response(child.stderr).text()).toBe(0)
})
}),
),
30_000,
)
it.live(
"auto-refreshes a legacy Git install once without overwriting it",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.promise(() => createGitFixture(tmp.path))
const cache = path.join(tmp.path, "cache")
const spec = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
const root = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(spec)))
const initial = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.promise(async () => {
// Recreate the shipped pre-generation cache layout, including its real npm lockfile.
await fs.cp(path.join(root, "generations", initial.generation), root, { recursive: true, dereference: true })
await fs.rm(path.join(root, "current.json"))
await fs.rm(path.join(root, "revisions"), { recursive: true })
await fs.rm(path.join(root, "generations"), { recursive: true })
})
const secondCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "second"))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const legacy = yield* npm.resolve(spec)
expect(legacy.revision).toBe(fixture.commit)
expect(legacy.generation).toStartWith("legacy-")
const updated = yield* npm.add(spec, { refresh: true })
expect(updated.revision).toBe(secondCommit)
expect(updated.directory).not.toBe(legacy.directory)
yield* Effect.promise(() => advanceGitFixture(fixture.repository, "third"))
expect(yield* npm.add(spec, { refresh: true })).toEqual(updated)
expect(yield* Effect.promise(() => Bun.file(path.join(legacy.directory, "relative.js")).text())).toContain(
'"first"',
)
yield* Effect.promise(() => fs.rename(fixture.repository, `${fixture.repository}-offline`))
const reloaded = yield* npm.reload(spec, { generation: legacy.generation, revision: legacy.revision })
expect(reloaded.revision).toBe(fixture.commit)
expect((yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))).dependency.value).toBe("first")
expect(yield* npm.resolve(spec)).toEqual(updated)
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"uses npm configuration, stages exact registry revisions, and retains the old graph after a failed tarball",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.promise(() => fixture.configure(cache, pinned))
const updated = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.inspect(spec)).toEqual({ installed: undefined, mutable: true })
expect(yield* npm.check(spec)).toEqual({ installed: undefined, available: "1.0.0", mutable: true })
expect((yield* npm.resolve(spec)).entrypoint).toBeUndefined()
const first = yield* npm.add(spec, { refresh: true })
const pin = yield* npm.add(pinned)
const firstModule = yield* Effect.promise(() => import(first.entrypoint ?? ""))
fixture.state.latest = "1.1.0"
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
expect(yield* npm.resolve(spec)).toEqual(first)
const second = yield* npm.update(spec, { subpaths: [""] })
expect(second.revision).toBe("1.1.0")
expect((yield* Effect.promise(() => import(second.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect((yield* npm.update(spec, { subpaths: ["missing"] }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect(yield* npm.resolve(spec)).toEqual(second)
const companion = yield* npm.add(spec, { revision: "1.2.0", subpaths: ["tui"] })
expect(companion.revision).toBe("1.2.0")
expect(yield* npm.resolve(spec)).toEqual(second)
fixture.state.latest = "1.3.0"
expect((yield* npm.check(spec)).available).toBe("1.3.0")
const before = yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))
expect((yield* npm.update(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect(yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).toEqual(before)
expect(yield* npm.resolve(spec)).toEqual(second)
expect((yield* npm.add(spec, { revision: "latest" }).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
const requests = fixture.state.requests
fixture.state.offline = true
expect(yield* npm.check(pinned)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: false })
expect(yield* npm.update(pinned)).toEqual(pin)
const reloaded = yield* npm.reload(spec)
expect(reloaded.revision).toBe("1.1.0")
expect(fixture.state.requests).toBe(requests)
expect((yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect(firstModule.relative.value).toBe("1.0.0")
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"1.0.0"',
)
return reloaded
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(updated)
expect((yield* npm.resolve(spec, { revision: "1.2.0" })).revision).toBe("1.2.0")
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
})
+2 -1
View File
@@ -5,6 +5,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
@@ -121,7 +122,7 @@ describe("Permission", () => {
}),
)
it.effect("evaluates against an explicit agent", () =>
it.effect("evaluates against an explicit provider-turn agent", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const agents = yield* Agent.Service
+65 -181
View File
@@ -1,15 +1,15 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Cause, Context, Effect, Exit, Fiber, Latch, Schema, Stream } from "effect"
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -27,178 +27,6 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("closes the entire suffix after cleanup defects without leaving old hooks behind", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* PluginHooks.Service
const closed: string[] = []
const observed: string[] = []
const definition = (id: string, version: string, broken = false) => ({
id,
version,
effect: (ctx: EffectPlugin.Context) =>
Effect.gen(function* () {
yield* ctx.session.hook("prompt", () =>
Effect.sync(() => {
observed.push(`${id}${version}`)
}),
)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed.push(`${id}${version}`)
if (broken) throw new Error("cleanup failed")
}),
)
}),
})
const first = [definition("a", "1"), definition("b", "1"), definition("c", "1", true)]
yield* plugins.activate(first)
yield* plugins.activate([definition("a", "2"), ...first.slice(1)])
expect(closed).toEqual(["c1", "b1", "a1"])
expect((yield* plugins.list()).map((plugin) => [plugin.id, plugin.status])).toEqual(
["a", "b", "c"].map((id) => [Plugin.ID.make(id), "active"]),
)
yield* plugins.activate([definition("a", "2"), definition("b", "2"), definition("c", "2")])
yield* registry.trigger("session", "prompt", {
sessionID: Session.ID.make("ses_cleanup"),
messageID: SessionMessage.ID.make("msg_cleanup"),
prompt: { text: "hello" },
delivery: "steer",
metadata: undefined,
})
expect(observed).toEqual(["a2", "b2", "c2"])
expect(closed).toEqual(["c1", "b1", "a1", "c1", "b1"])
yield* plugins.activate([])
expect(closed.slice(-3)).toEqual(["c2", "b2", "a2"])
expect(yield* plugins.list()).toEqual([])
}),
)
it.effect("does not gate an unchanged generation while a Session lease is held", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const gate = yield* PluginExecution.Service
const definitions = [{ id: "unchanged", version: "1", effect: () => Effect.void }]
yield* plugins.activate(definitions)
yield* gate.lease({ id: "running" }, plugins.activate(definitions))
expect((yield* plugins.list())[0]?.status).toBe("active")
}),
)
it.effect("bounds inventory errors and removes URL credentials and query strings", () =>
Effect.sync(() => {
const error = Plugin.errorMessage(
Cause.die(new Error(`https://user:password@example.com/plugin?token=secret ${"x".repeat(3000)}`)),
)
expect(error.length).toBeLessThanOrEqual(2000)
expect(error).toContain("https://example.com/plugin")
expect(error).not.toContain("password")
expect(error).not.toContain("token=secret")
}),
)
it.effect("keeps the unchanged registration prefix and preserves suffix ordering", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const loads: string[] = []
const closed: string[] = []
const definition = (id: string, version: string) => ({
id,
version,
effect: (ctx: EffectPlugin.Context) =>
Effect.gen(function* () {
loads.push(`${id}:${version}`)
yield* Effect.addFinalizer(() => Effect.sync(() => closed.push(id)))
yield* ctx.agent.transform((draft) =>
draft.update("ordered", (agent) => {
agent.description = `${agent.description ?? ""}${id}:${version};`
}),
)
}),
})
const prefix = definition("prefix", "1")
const suffix = definition("suffix", "1")
yield* plugins.activate([prefix, definition("target", "1"), suffix])
yield* plugins.activate([prefix, definition("target", "2"), suffix])
expect(loads).toEqual(["prefix:1", "target:1", "suffix:1", "target:2", "suffix:1"])
expect(closed).toEqual(["suffix", "target"])
expect((yield* agents.get(Agent.ID.make("ordered")))?.description).toBe("prefix:1;target:2;suffix:1;")
}),
)
it.effect("does not close plugin resources while a leased snapshot executes", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const gate = yield* PluginExecution.Service
const started = yield* Latch.make()
const finish = yield* Latch.make()
let closed = false
yield* plugins.activate([
{
id: "leased",
version: "1",
revision: "1.0.0",
generation: "first",
tui: true,
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
}),
)
yield* ctx.tool.transform((draft) =>
draft.add({
name: "leased",
description: "Leased tool",
input: Schema.Struct({}),
options: { codemode: false },
execute: () =>
Effect.gen(function* () {
yield* started.open
yield* finish.await
expect(closed).toBe(false)
return { content: "first" }
}),
}),
)
}),
},
])
const running = yield* gate
.lease(
{ id: "ses_running" },
Effect.gen(function* () {
const snapshot = yield* tools.snapshot()
return yield* snapshot.execute({
sessionID: Session.ID.make("ses_running"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_message"),
call: { type: "tool-call", id: "call", name: "leased", input: {} },
})
}),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* started.await
const replacement = yield* plugins
.activate([
{
id: "leased",
version: "2",
revision: "2.0.0",
generation: "second",
effect: () => Effect.void,
},
])
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(closed).toBe(false)
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* finish.open
yield* Fiber.join(running)
yield* Fiber.join(replacement)
expect(closed).toBe(true)
expect((yield* plugins.list())[0]?.revision).toBe("2.0.0")
}),
)
it.effect("exposes the current location to activated plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -227,6 +55,64 @@ describe("Plugin", () => {
}),
)
it.effect("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const resolved: (Credential.Value | undefined)[] = []
const refreshed: Credential.OAuth[] = []
yield* plugins.activate([
versioned(
EffectPlugin.define({
id: "oauth-refresh",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () => Effect.die("unexpected authorization"),
refresh: (value) =>
Effect.sync(() => {
refreshed.push(value)
return fresh
}),
}),
)
const connection = yield* ctx.integration.connection.active(integrationID)
if (!connection) return yield* Effect.die("stored connection missing")
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
}),
}),
),
])
expect(resolved).toEqual([fresh])
expect(refreshed).toEqual([expired])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -574,18 +460,16 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([{ ...versioned(previous), revision: "1.0.0", generation: "previous", tui: true }])
yield* plugins.activate([{ ...versioned(replacement, "2"), revision: "2.0.0", generation: "replacement" }])
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "active",
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: true,
revision: "1.0.0",
generation: "previous",
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
-152
View File
@@ -1,152 +0,0 @@
import { expect } from "bun:test"
import { Effect, Fiber, Latch } from "effect"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { testEffect } from "../lib/effect"
const it = testEffect(PluginExecution.layer)
it.effect("defers activation through awaited children and titles, blocking unrelated selections", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const childAllowed = yield* Latch.make()
const titleFinished = yield* Latch.make()
const events: string[] = []
const parent = yield* gate
.lease(
{ id: "parent" },
Effect.gen(function* () {
yield* childAllowed.await
yield* gate.lease(
{ id: "child", parentID: "parent" },
Effect.gen(function* () {
yield* gate.lease(
{ id: "grandchild", parentID: "child" },
Effect.sync(() => events.push("child")),
)
}),
)
yield* gate.lease(
{ id: "new-prompt", admission: true },
Effect.sync(() => events.push("admitted")),
)
events.push("parent")
}),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const title = yield* gate
.lease(
{ id: "parent", admission: true },
titleFinished.await.pipe(Effect.andThen(Effect.sync(() => events.push("title")))),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const activation = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const unrelated = yield* gate
.lease(
{ id: "unrelated" },
Effect.sync(() => events.push("selection")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* childAllowed.open
yield* Fiber.join(parent)
expect(events).toEqual(["child", "admitted", "parent"])
yield* titleFinished.open
yield* Fiber.join(title)
yield* Fiber.join(activation)
yield* Fiber.join(unrelated)
expect(events).toEqual(["child", "admitted", "parent", "title", "activation", "selection"])
}),
)
it.effect("releases reader leases and pending admission on interruption", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const reader = yield* gate.lease({ id: "reader" }, Effect.never).pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate.exclusive(Effect.never).pipe(Effect.forkScoped({ startImmediately: true }))
const waiting = yield* gate
.lease({ id: "waiting" }, Effect.never)
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Fiber.interrupt(waiting)
yield* Fiber.interrupt(writer)
expect(yield* gate.lease({ id: "new" }, Effect.succeed("open"))).toBe("open")
yield* Fiber.interrupt(reader)
expect(yield* gate.exclusive(Effect.succeed("idle"))).toBe("idle")
}),
)
it.effect("does not let admission-only work establish same-Session or child execution ownership", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const finishParent = yield* Latch.make()
const finishAdmission = yield* Latch.make()
const events: string[] = []
const parent = yield* gate
.lease({ id: "parent" }, finishParent.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const admission = yield* gate
.lease({ id: "unrelated", admission: true }, finishAdmission.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const unrelated = yield* gate
.lease(
{ id: "unrelated" },
Effect.sync(() => events.push("unrelated")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const child = yield* gate
.lease(
{ id: "child", parentID: "unrelated" },
Effect.sync(() => events.push("child")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* finishParent.open
yield* Fiber.join(parent)
expect(events).toEqual([])
yield* finishAdmission.open
yield* Fiber.join(admission)
yield* Fiber.join(writer)
yield* Fiber.join(unrelated)
yield* Fiber.join(child)
expect(events[0]).toBe("activation")
expect(events.slice(1).toSorted()).toEqual(["child", "unrelated"])
}),
)
it.effect("a title-only helper protects its resources without allowing an idle Session or child to drain", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const finishTitle = yield* Latch.make()
const events: string[] = []
const title = yield* gate
.lease({ id: "idle", admission: true }, finishTitle.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const session = yield* gate
.lease(
{ id: "idle" },
Effect.sync(() => events.push("session")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const child = yield* gate
.lease(
{ id: "child", parentID: "idle" },
Effect.sync(() => events.push("child")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* finishTitle.open
yield* Fiber.join(title)
yield* Fiber.join(writer)
yield* Fiber.join(session)
yield* Fiber.join(child)
expect(events[0]).toBe("activation")
expect(events.slice(1).toSorted()).toEqual(["child", "session"])
}),
)
+2 -8
View File
@@ -19,7 +19,6 @@ import { Mcp } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
@@ -36,12 +35,8 @@ import { emptyMcpLayer } from "../fixture/mcp"
const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
}),
)
@@ -83,7 +78,6 @@ export const PluginTestLayer = LayerNode.compile(
PluginRuntime.node,
Permission.node,
PluginHooks.node,
PluginExecution.node,
Reference.node,
Skill.node,
SkillDiscovery.node,
-3
View File
@@ -98,9 +98,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
check: () => Effect.die("unused plugin.check"),
update: () => Effect.die("unused plugin.update"),
reload: () => Effect.die("unused plugin.reload"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
+3 -22
View File
@@ -915,7 +915,7 @@ describe("fromPromise", () => {
}),
)
it.effect("returns content-only plugin results and rejected Promises through Code Mode", () =>
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
@@ -927,11 +927,8 @@ describe("fromPromise", () => {
tools.add({
name: "demo_status",
description: "Returns a status string",
input: Schema.Struct({ fail: Schema.optionalKey(Schema.Boolean) }),
execute: async ({ fail }) => {
if (fail) await ctx.session.create({ agent: undefined })
return { content: [{ type: "text", text: "hello" }] }
},
input: Schema.Struct({}),
execute: async () => ({ content: [{ type: "text", text: "hello" }] }),
options: { codemode: true },
})
})
@@ -956,22 +953,6 @@ describe("fromPromise", () => {
output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
expect(
yield* toolSet.execute({
sessionID: Session.ID.make("ses_content_only_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_content_only_tool"),
call: {
type: "tool-call",
id: "call_failed_tool",
name: "execute",
input: { code: "return await tools.demo_status({ fail: true })" },
},
}),
).toMatchObject({
content: [{ type: "text", text: 'Expected string | null\n at ["agent"]' }],
metadata: { error: true },
})
}),
)
})
@@ -22,12 +22,8 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
})
}
@@ -13,12 +13,8 @@ import { PluginTestLayer } from "./fixture"
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, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
})
@@ -1,129 +0,0 @@
import { expect } from "bun:test"
import { Effect, Fiber, Latch, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { App } from "@opencode-ai/core/app"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionGenerate } from "@opencode-ai/core/session/generate"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Session.node,
SessionProjector.node,
LocationServiceMap.node,
PluginRuntime.providerNode,
PluginRuntime.node,
KV.node,
App.node,
]),
[
[Global.node, tempGlobalLayer],
[Bus.node, Bus.configured({ persist: true })],
[SessionExecution.node, SessionExecution.noopLayer],
[
SessionGenerateNode.node,
Layer.succeed(
SessionGenerate.Service,
SessionGenerate.Service.of({
generate: () => Effect.succeed("generated"),
}),
),
],
],
),
)
for (const mode of ["Effect", "Promise"]) {
it.live(`${mode} prompt hooks can await auxiliary Session generation without granting drain ownership`, () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-plugin-reentry-")))
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
const session = yield* sessions.create({ location: ref })
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const registry = yield* Plugin.Service
const host = yield* PluginHost.make(registry)
const entered = yield* Latch.make()
const proceed = yield* Latch.make()
const events: string[] = []
if (mode === "Effect") {
yield* host.session.hook(
"prompt",
(event) =>
Effect.gen(function* () {
yield* entered.open
yield* proceed.await
events.push(
(yield* host.session
.generate({ sessionID: event.sessionID, prompt: "Generate inside admission" })
.pipe(Effect.orDie)).text,
)
}),
undefined,
)
}
if (mode === "Promise") {
yield* PluginPromise.fromPromise({
id: "nested-generation",
setup: async (ctx) => {
await ctx.session.hook("prompt", async (event) => {
await Effect.runPromise(entered.open)
await Effect.runPromise(proceed.await)
events.push(
(await ctx.session.generate({ sessionID: event.sessionID, prompt: "Generate inside admission" }))
.text,
)
})
},
}).effect(host)
}
const preparing = yield* sessions
.prompt({ sessionID: session.id, text: "hello", resume: false })
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => proceed.open)
yield* entered.await
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const drain = yield* gate
.lease(
{ id: session.id },
Effect.sync(() => events.push("drain")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* proceed.open
yield* Fiber.join(preparing)
yield* Fiber.join(writer)
yield* Fiber.join(drain)
expect(events).toEqual(["generated", "activation", "drain"])
}).pipe(Effect.provide(locations.get(ref)))
}),
)
}

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