mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 12:36:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07b5d00fb0 | ||
|
|
73423fd3e2 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Preserve provider metadata on assistant text when replaying conversation history through AI SDK providers.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct the published Newtype documentation to recommend `Schema.decodeUnknownSync` for schema-based synchronous decoding.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Forward plugin Session interrupt options so continuation requests reach the Session service.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) })
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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: [] })
|
||||
@@ -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()),
|
||||
|
||||
@@ -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") {}
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ const layer = Layer.effect(
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () =>
|
||||
notify: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {} }`
|
||||
}
|
||||
|
||||
@@ -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,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,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>,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user