mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-19 11:47:02 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9140c8593 | |||
| 52e3a3f3c7 | |||
| 2d7abb8437 | |||
| 1914657dd7 | |||
| 74f83cb226 | |||
| ee62824e4f |
@@ -966,6 +966,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -975,6 +976,7 @@
|
||||
"@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:",
|
||||
},
|
||||
},
|
||||
@@ -1073,9 +1075,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2212,27 +2214,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-darwin-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-iJKwj+aq0w0TYZu0xafb5Nj5eBObZcKc4+qHCpj5EAQBRCid3Z8nROmiwdO+aDDeqwTYzfG53u+MU3urEtiweQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-8Wo6B6VhFXu5Lq/HDXQCkxCJF4svJ3OPU+WcotQJuLT9/pXzik40WjTKVn3HECKiqQ3ryQS4ZxpnJIxlNDcpmg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-Tt+qGxp5VJZKmw54XdzebE6a+0QVA5Sk8KWUDHfWXn4ley6tvpMZowrG2/O6TpKHP6XdA/bHm+Ewetyk7BO2Zg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-p62IQHNFFQUzet57HMQndNQZtunDc0peSHudBoatCTV68QYKsD5gYjAK/BW1XY0zUoJM9Jk7BJnPBJPv1s5EYQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-qvQgkAdLSa1+SeMhMNBZ7zx7OlVi8yMsV+DDGTDrcc9sWXDzGJEKUq/R9XqqAag64Sloc12YA5oUYDKKj4E2VA=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-JIkwBP6yLhh4ViTR53kx/NCY5w1ocTcKMdWidpObKLVLIkBat97y085teSvgkh+RFuJnUaJK7114XgdEONCjSg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-M1BPZZ7eLE/nfnS7uypyIxg5JKfkkZSkehszISrQOf8zQl3+j3kOPy17RqSCJKZboGFQwmg8rwlaxGNxA6sfVQ=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-GFQ1GCA4FQg/MVpO1jt53X4fTaYsQTfW1EkOpQqmt/Rvls/oSklpElxh4ww5/giHEVnNxvcqX8+RG9U5DCmLpg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-NKFUOLwBfd59koYiPgeqrP4hZERnG6B7+RnH2DNO3dAnIAl/7dISuOwWw/VacYBck4ToG0ZrbhBMTeBB2i5DqQ=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Lw+wxniAxIqnZz9vFPwl8EsH33HZGnyukQTU/4dI01BwNKbx2hcxuFMjrHFP/MkqhvwmFXcvGSGfJrG8VLwFoA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-wtq+offTD/Hxd9KXW5eVjeBgtBiJiXakkk1Jo/8ULX2fvG+trtdaAHr0DrlHsRv/xyUMfxPBaHRwPe4Gk8+8sw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -5812,7 +5814,7 @@
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
@@ -6564,10 +6566,6 @@
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
|
||||
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
|
||||
|
||||
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -6646,8 +6644,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7454,29 +7450,11 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
|
||||
"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=="],
|
||||
|
||||
@@ -8184,6 +8162,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -8276,8 +8256,6 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -85,10 +85,10 @@ export default Runtime.handler(Commands, (input) =>
|
||||
},
|
||||
packages: {
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })),
|
||||
check: (spec) =>
|
||||
runPromise(
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
npm.check(spec).pipe(Effect.mapError((error) => (error.cause instanceof Error ? error.cause : error))),
|
||||
),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
|
||||
@@ -88,8 +88,16 @@ 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 interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -318,7 +320,17 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
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 adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
})
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
|
||||
@@ -32,6 +32,7 @@ export { Location } from "@opencode-ai/schema/location"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Plugin } from "@opencode-ai/schema/plugin"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -462,6 +464,19 @@ 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,
|
||||
),
|
||||
},
|
||||
session: {
|
||||
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
|
||||
@@ -1367,7 +1382,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1496,7 +1511,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -16,6 +16,8 @@ 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
|
||||
@@ -441,8 +443,8 @@ export type ProviderRequest = {
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type PluginInfo =
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
| { id: string; source: PluginSource; revision?: string; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; revision?: string; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
@@ -2338,6 +2340,10 @@ export type AgentNotFoundError = {
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type PluginCheckError = { readonly _tag: "PluginCheckError"; readonly message: string }
|
||||
export const isPluginCheckError = (value: unknown): value is PluginCheckError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginCheckError"
|
||||
|
||||
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"
|
||||
@@ -2548,6 +2554,18 @@ 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 SessionListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
@@ -13,6 +14,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Agent).toBe(Agent)
|
||||
expect(Client.Config).toBe(Config)
|
||||
expect(Client.Model).toBe(Model)
|
||||
expect(Client.Plugin).toBe(Plugin)
|
||||
expect(Client.Session).toBe(Session)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Location,
|
||||
Model,
|
||||
OpenCode,
|
||||
Plugin,
|
||||
Prompt,
|
||||
Session,
|
||||
SessionMessage,
|
||||
@@ -27,6 +28,40 @@ test("health.get decodes the readiness response", async () => {
|
||||
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
|
||||
})
|
||||
|
||||
test("plugin.check decodes package status and its declared error", async () => {
|
||||
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.url).toBe("http://localhost:3000/api/plugin/check")
|
||||
expect(Object.fromEntries(request.urlParams.params)).toEqual({ "location[directory]": "/tmp/project" })
|
||||
const body =
|
||||
request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined
|
||||
expect(Object.keys(body)).toEqual(["target"])
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
body.target === "missing"
|
||||
? Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
|
||||
: Response.json({
|
||||
location: {
|
||||
directory: "/tmp/project",
|
||||
project: { id: "proj_test", directory: "/tmp/project", canonical: "/tmp/project" },
|
||||
},
|
||||
data: status,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const location = { directory: "/tmp/project" }
|
||||
expect((yield* client.plugin.check({ target: "fixture-plugin@^1", location })).data).toEqual(status)
|
||||
const error = yield* client.plugin.check({ target: "missing", location }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Plugin.CheckError)
|
||||
expect(error.message).toBe("Not a configured plugin source")
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
})
|
||||
|
||||
test("session.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
|
||||
import { isPluginCheckError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
|
||||
|
||||
test("exposes every standard HTTP API group", () => {
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
@@ -55,6 +55,34 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
expect(Object.keys(client.plugin)).toEqual(["list", "check"])
|
||||
})
|
||||
|
||||
test("plugin.check uses the inspection contract and retains declared errors", async () => {
|
||||
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.url).toBe("http://localhost:3000/api/plugin/check?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
const body = await request.json()
|
||||
expect(Object.keys(body)).toEqual(["target"])
|
||||
if (body.target === "missing")
|
||||
return Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
|
||||
expect(body.target).toBe("fixture-plugin@^1")
|
||||
return Response.json({ location: { directory: "/tmp/project" }, data: status })
|
||||
},
|
||||
})
|
||||
expect(
|
||||
(await client.plugin.check({ target: "fixture-plugin@^1", location: { directory: "/tmp/project" } })).data,
|
||||
).toEqual(status)
|
||||
const error = await client.plugin
|
||||
.check({ target: "missing", location: { directory: "/tmp/project" } })
|
||||
.catch((cause: unknown) => cause)
|
||||
expect(isPluginCheckError(error)).toBe(true)
|
||||
if (!isPluginCheckError(error)) throw new Error("Expected PluginCheckError")
|
||||
expect(error.message).toBe("Not a configured plugin source")
|
||||
})
|
||||
|
||||
test("config.get returns ordered config entries for a location", async () => {
|
||||
|
||||
Vendored
-2
@@ -1,7 +1,5 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
|
||||
import "@solidjs/start"
|
||||
|
||||
export declare module "@solidjs/start/server" {
|
||||
export type APIEvent = { request: Request }
|
||||
}
|
||||
|
||||
@@ -482,7 +482,8 @@ function toolMessage(input: LLMRequest["messages"][number]) {
|
||||
const value = part.result.value.filter((item) => {
|
||||
if (item.type !== "file") return true
|
||||
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
|
||||
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
|
||||
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
|
||||
return false
|
||||
})
|
||||
return toolResultPart({
|
||||
@@ -506,7 +507,7 @@ function text(part: ContentPart) {
|
||||
function userPart(part: ContentPart): UserContent {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "media")
|
||||
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -515,7 +516,7 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
case "text":
|
||||
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "media":
|
||||
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
case "reasoning":
|
||||
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "tool-call":
|
||||
@@ -534,15 +535,6 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
}
|
||||
}
|
||||
|
||||
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
|
||||
if (typeof data !== "string") return data
|
||||
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
|
||||
if (base64 !== undefined) return base64
|
||||
if (!URL.canParse(data)) return data
|
||||
const url = new URL(data)
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url : data
|
||||
}
|
||||
|
||||
function toolResultPart(part: ContentPart): ToolResultContent[] {
|
||||
if (part.type !== "tool-result") return []
|
||||
return [
|
||||
|
||||
@@ -859,12 +859,16 @@ export function configured(options?: Options) {
|
||||
aggregateID: input.aggregateID,
|
||||
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
|
||||
}
|
||||
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(Stream.concat(Stream.make(marker)))
|
||||
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
|
||||
Stream.map((event): LogItem => event),
|
||||
Stream.concat(Stream.make(marker)),
|
||||
)
|
||||
if (!wakes) return replay
|
||||
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
|
||||
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
|
||||
Stream.filter((target) => target > sequence),
|
||||
Stream.flatMap((target) => readThrough(target)),
|
||||
Stream.map((event): LogItem => event),
|
||||
)
|
||||
return Stream.concat(replay, live)
|
||||
}),
|
||||
|
||||
@@ -20,13 +20,14 @@ export const Plugin = define({
|
||||
const global = yield* Global.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
draft.add(
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
@@ -47,6 +48,7 @@ export const Plugin = define({
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Effect Drizzle SQLite Adapter
|
||||
|
||||
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
|
||||
builders over Effect's generic `SqlClient`. It is maintained source, not
|
||||
generated output.
|
||||
|
||||
## Provenance
|
||||
|
||||
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
|
||||
SQLite Effect query builders, and shared query-builder utilities. The
|
||||
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
|
||||
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
|
||||
|
||||
The exact upstream revision originally copied into this repository is unknown.
|
||||
The currently pinned `drizzle-orm` version is a compatibility dependency, not
|
||||
copy provenance.
|
||||
|
||||
## Local Boundary
|
||||
|
||||
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
|
||||
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
|
||||
facade from `database/database.ts`.
|
||||
|
||||
Material local adaptations include:
|
||||
|
||||
- a runtime-independent driver over Effect's generic `SqlClient`
|
||||
- local cache, mapping, and runtime-inspection helpers
|
||||
- suppressed statement tracing beneath the database operation boundary
|
||||
- explicit SQLite transactions and savepoints
|
||||
- native transaction delegation for Durable Object SQLite
|
||||
- deliberate query-builder variance annotations
|
||||
|
||||
Preserve these adaptations when comparing or synchronizing upstream code.
|
||||
Focused regression coverage is in `test/database-drizzle.test.ts` and
|
||||
`test/sqlite-workerd.test.ts`.
|
||||
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { SqliteClient } from "@effect/sql-sqlite-node"
|
||||
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
* import { Effect } from "effect"
|
||||
* import { SqliteClient } from '@effect/sql-sqlite-node';
|
||||
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
|
||||
* import * as Effect from 'effect/Effect';
|
||||
*
|
||||
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
|
||||
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
|
||||
* )
|
||||
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
|
||||
* Effect.provide(SQLiteDrizzle.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
|
||||
|
||||
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
|
||||
: undefined
|
||||
on = on(
|
||||
new Proxy(
|
||||
getTableColumnsRuntime(this.config.table),
|
||||
this.config.table._.columns,
|
||||
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
|
||||
) as any,
|
||||
from &&
|
||||
|
||||
@@ -481,7 +481,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
|
||||
if (runtimeState.status === "error") return runtimeState
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
return { status: "required" as const }
|
||||
})
|
||||
}).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
@@ -521,75 +521,76 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
const state = yield* readState(db)
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
const migrate = Effect.gen(function* () {
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* tx.run(sql`
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* tx.run(sql`
|
||||
DELETE FROM event
|
||||
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
|
||||
`)
|
||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
cursor !== undefined
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
const projects = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
|
||||
)
|
||||
while (true) {
|
||||
const state = yield* readState(db)
|
||||
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const nextID = yield* db.get<{ id: string; project_id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
cursor !== undefined
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
const projects = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
|
||||
if (projectID !== nextID.project_id)
|
||||
yield* Effect.logWarning("Reassigned V1 session with missing project", {
|
||||
sessionID: nextID.id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
yield* tx.run(sql`
|
||||
while (true) {
|
||||
const state = yield* readState(db)
|
||||
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const nextID = yield* db.get<{ id: string; project_id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
|
||||
if (projectID !== nextID.project_id)
|
||||
yield* Effect.logWarning("Reassigned V1 session with missing project", {
|
||||
sessionID: nextID.id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
@@ -604,79 +605,81 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
FROM session
|
||||
WHERE id = ${nextID.id}
|
||||
`)
|
||||
const next = yield* tx
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
|
||||
const sourceMessages = yield* tx.all<SourceMessage>(
|
||||
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const sourceParts = yield* tx.all<SourcePart>(
|
||||
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
|
||||
yield* Effect.forEach(transformed.warnings, (warning) =>
|
||||
Effect.logWarning("Skipped V1 migration row", warning),
|
||||
)
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${JSON.stringify(message.data)}`,
|
||||
const next = yield* tx
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
|
||||
const sourceMessages = yield* tx.all<SourceMessage>(
|
||||
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const sourceParts = yield* tx.all<SourcePart>(
|
||||
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
|
||||
yield* Effect.forEach(transformed.warnings, (warning) =>
|
||||
Effect.logWarning("Skipped V1 migration row", warning),
|
||||
)
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${JSON.stringify(message.data)}`,
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.update(SessionTable)
|
||||
.set({ ...transformed.session, time_updated: next.time_updated })
|
||||
.where(eq(SessionTable.id, next.id))
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: next.id, seq: transformed.watermark })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (runtimeState.status === "running")
|
||||
runtimeState = {
|
||||
status: "running",
|
||||
progress: {
|
||||
label: "Migrating sessions",
|
||||
numerator: (runtimeState.progress.numerator ?? 0) + 1,
|
||||
denominator,
|
||||
},
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.update(SessionTable)
|
||||
.set({ ...transformed.session, time_updated: next.time_updated })
|
||||
.where(eq(SessionTable.id, next.id))
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: next.id, seq: transformed.watermark })
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "completed" }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (runtimeState.status === "running")
|
||||
runtimeState = {
|
||||
status: "running",
|
||||
progress: {
|
||||
label: "Migrating sessions",
|
||||
numerator: (runtimeState.progress.numerator ?? 0) + 1,
|
||||
denominator,
|
||||
},
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "completed" }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return { status: "completed" as const }
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
return yield* migrate
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
@@ -705,7 +708,7 @@ function countNextSessions(sourcePath: string | undefined) {
|
||||
if (!isNextDatabase(source)) return 0
|
||||
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
|
||||
}),
|
||||
)
|
||||
).pipe(Effect.orElseSucceed(() => 0))
|
||||
}
|
||||
|
||||
function importNextDatabase(
|
||||
|
||||
@@ -61,23 +61,21 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
stat: (value) =>
|
||||
Effect.suspend(() => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
}),
|
||||
read: (value, range) =>
|
||||
Effect.gen(function* () {
|
||||
const original = lookup(value)
|
||||
if (!original) return yield* new NotFound({ path: value })
|
||||
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return yield* new NotFound({ path: value })
|
||||
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return { info: info(node), bytes: bytes.slice() }
|
||||
}),
|
||||
stat: (value) => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
},
|
||||
read: (value, range) => {
|
||||
const original = lookup(value)
|
||||
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||
},
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
@@ -91,17 +89,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
list: (value) =>
|
||||
Effect.gen(function* () {
|
||||
const target = resolveKey(value, true) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return yield* new NotFound({ path: value })
|
||||
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
|
||||
return [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
list: (value) => {
|
||||
const target = resolveKey(value, true) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const entries = [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Effect.succeed(entries)
|
||||
},
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
@@ -109,33 +107,32 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
move: (from, to) =>
|
||||
Effect.gen(function* () {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return yield* new NotFound({ path: from })
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
}),
|
||||
move: (from, to) => {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
},
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
# GitHub Copilot AI SDK Adapters
|
||||
This is a temporary package used primarily for GitHub Copilot compatibility.
|
||||
|
||||
This directory contains upstream-derived AI SDK implementations adapted for
|
||||
GitHub Copilot. It is not a generic OpenAI-compatible provider.
|
||||
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
|
||||
|
||||
## Provenance
|
||||
|
||||
- `chat/` is derived from the Vercel AI SDK
|
||||
`@ai-sdk/openai-compatible` chat implementation.
|
||||
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
|
||||
implementation.
|
||||
- The exact upstream revisions originally copied into this repository are
|
||||
unknown. Current dependency versions and the `VERSION` constant in
|
||||
`copilot-provider.ts` are not copy provenance.
|
||||
|
||||
## Ownership
|
||||
|
||||
Keep `chat/` and `responses/` structurally close to their upstream modules, but
|
||||
preserve the intentional Copilot adaptations: the `copilot` options and metadata
|
||||
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
|
||||
Responses requests with encrypted reasoning, rotating response item IDs, and
|
||||
explicit function-tool strictness taking precedence over the global fallback.
|
||||
|
||||
`copilot-provider.ts` is the local adapter assembly entrypoint used by
|
||||
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
|
||||
reconciliation, not vendored SDK code. Authentication, request headers, model
|
||||
routing, and integration lifecycle are also owned by the provider plugin.
|
||||
|
||||
When updating the upstream-shaped modules, compare against both source packages
|
||||
and reapply the documented Copilot adaptations. Focused regression coverage is
|
||||
in:
|
||||
|
||||
- `test/github-copilot/copilot-chat-model.test.ts`
|
||||
- `test/github-copilot/convert-to-copilot-messages.test.ts`
|
||||
- `test/github-copilot/openai-responses-language-model.test.ts`
|
||||
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
|
||||
- `test/github-copilot/models.test.ts`
|
||||
- `test/plugin/provider-github-copilot.test.ts`
|
||||
Avoid making edits to these files
|
||||
|
||||
@@ -653,11 +653,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
if (isActiveText) {
|
||||
controller.enqueue({
|
||||
type: "text-end",
|
||||
id: "txt-0",
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
controller.enqueue({ type: "text-end", id: "txt-0" })
|
||||
}
|
||||
|
||||
// go through all tool calls and send the ones that are not finished
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
|
||||
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
|
||||
@@ -33,7 +33,12 @@ export const make = Effect.gen(function* () {
|
||||
return Effect.fn("Image.Photon.normalize")(function* (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
limits: Readonly<Limits>,
|
||||
limits: {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
},
|
||||
) {
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
|
||||
@@ -37,7 +37,6 @@ export function buildLocationServiceMap(
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { McpStdio } from "./stdio.js"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
@@ -157,7 +156,6 @@ export interface Connection {
|
||||
readonly callTool: (input: {
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server emits an MCP logging notification. */
|
||||
@@ -398,11 +396,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.callTool(
|
||||
{
|
||||
name: input.name,
|
||||
arguments: input.args ?? {},
|
||||
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
|
||||
},
|
||||
{ name: input.name, arguments: input.args ?? {} },
|
||||
CallToolResultSchema,
|
||||
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
|
||||
{ signal, timeout: executionTimeout, onprogress: () => {} },
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as Mcp from "./index.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { ephemeral } from "@opencode-ai/schema/event"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
@@ -154,7 +153,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
||||
@@ -764,7 +762,7 @@ export const layer = (options?: Options) =>
|
||||
message: "MCP server is not connected",
|
||||
})
|
||||
const result = yield* target.entry.client
|
||||
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
|
||||
.callTool({ name: input.name, args: input.args })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
@@ -26,9 +26,19 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
|
||||
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export type Info = PersistentPty.Info
|
||||
export type Info = Pty.Info & {
|
||||
readonly sessionID: Session.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
|
||||
export type Snapshot = PersistentPty.Snapshot
|
||||
export type Snapshot = {
|
||||
readonly info: Info
|
||||
readonly text: string
|
||||
readonly checkpoint: Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
export type Attachment = {
|
||||
readonly info: Info
|
||||
@@ -151,7 +161,15 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
input: Parameters<Interface["create"]>[1],
|
||||
input: {
|
||||
readonly command?: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) {
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
@@ -320,7 +338,14 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: Parameters<Interface["attach"]>[1],
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
export { CheckError, Event, ID, Info, PackageStatus, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -37,6 +37,7 @@ export interface Interface {
|
||||
|
||||
export type Versioned = PluginDefinition & {
|
||||
readonly version: string
|
||||
readonly revision?: string
|
||||
readonly source?: Plugin.Source
|
||||
}
|
||||
|
||||
@@ -119,18 +120,22 @@ const layer = Layer.effect(
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
const failed = {
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
...(definition.revision === undefined ? {} : { revision: definition.revision }),
|
||||
status: "failed",
|
||||
error: loaded.error,
|
||||
tui: definition.tui ?? false,
|
||||
})
|
||||
} satisfies Plugin.Info
|
||||
nextInventory.push(failed)
|
||||
|
||||
if (!previous) continue
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
failed.revision = previous.plugin.revision
|
||||
if (failed.revision === undefined) delete failed.revision
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
@@ -175,6 +180,7 @@ function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
|
||||
status: "active",
|
||||
tui: plugin.tui ?? false,
|
||||
}
|
||||
|
||||
@@ -84,9 +84,7 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
function append(template: string, input: string) {
|
||||
const value = input.trim()
|
||||
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
|
||||
return [template, value].filter(Boolean).join("\n\n")
|
||||
return [template, input.trim()].filter(Boolean).join("\n\n")
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as PluginHost from "./host.js"
|
||||
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 { CheckError } from "@opencode-ai/schema/plugin"
|
||||
import { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import { App } from "../app.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
@@ -332,6 +333,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
check: () =>
|
||||
Effect.fail(
|
||||
new CheckError({
|
||||
message: "Plugin package checks are available through an external client or the Plugins dialog.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
reference: {
|
||||
list: () => response(reference.list()),
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PluginModule from "./module.js"
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Predicate, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -34,13 +34,16 @@ const Definition = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
const revisions = new WeakMap<object, string | undefined>()
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
const resolved = path.isAbsolute(operation.target)
|
||||
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined }
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })
|
||||
const entrypoint = resolved.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" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
@@ -48,12 +51,16 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
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
|
||||
const exported = Predicate.hasProperty(mod, "default") && Predicate.isObject(mod.default) ? mod.default : undefined
|
||||
// Keep the import-time revision on the original export, not copied namespaces or decoded records.
|
||||
if (exported && !revisions.has(exported)) revisions.set(exported, resolved.revision)
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
vcs: plugin.vcs,
|
||||
version: JSON.stringify(operation),
|
||||
revision: exported ? revisions.get(exported) : undefined,
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: operation.target }
|
||||
: { type: "package" as const, package: operation.target },
|
||||
|
||||
@@ -76,13 +76,11 @@ to every project for that user. Project configuration can live in any directory
|
||||
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
|
||||
in a monorepo.
|
||||
|
||||
During ordinary project discovery, OpenCode searches the current Location
|
||||
directory and every ancestor through the filesystem root, including directories
|
||||
above the detected project or repository root. It merges direct
|
||||
`opencode.json(c)` files from the farthest ancestor to the current directory,
|
||||
When OpenCode starts, it searches from the current directory up to the project
|
||||
root. It merges direct `opencode.json(c)` files from root to current directory,
|
||||
then does the same for `.opencode/opencode.json(c)` files. This means every
|
||||
discovered `.opencode` config overrides every discovered direct config. Global
|
||||
filesystem configuration has lower precedence than these discovered documents.
|
||||
`.opencode` config overrides every direct config. Global configuration has the
|
||||
lowest precedence.
|
||||
|
||||
Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -9,6 +10,7 @@ import { Context, Effect } from "effect"
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.CheckError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
@@ -78,9 +78,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
|
||||
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,18 +114,6 @@ export const layer = Layer.effect(
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
if (resolved.refreshes.length) {
|
||||
yield* Effect.forEach(
|
||||
resolved.refreshes,
|
||||
(target) =>
|
||||
npm
|
||||
.add(target, { subpaths: ["server", ""], refresh: true })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.forkDetach)
|
||||
}
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
@@ -152,7 +137,29 @@ export const layer = Layer.effect(
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: ready.await })
|
||||
return Service.of({
|
||||
flush: ready.await,
|
||||
check: Effect.fn("PluginSupervisor.check")(function* (target: string) {
|
||||
yield* ready.await
|
||||
const inventory = yield* registry.list()
|
||||
if (!inventory.some((plugin) => plugin.source.type === "package" && plugin.source.package === target)) {
|
||||
return yield* new Plugin.CheckError({
|
||||
message: `Plugin package is not in the current server inventory: ${target}`,
|
||||
})
|
||||
}
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) {
|
||||
return yield* new Plugin.CheckError({ message: `Unsupported plugin package source: ${target}` })
|
||||
}
|
||||
return yield* npm.check(target).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new Plugin.CheckError({
|
||||
message: `Failed to check plugin package ${target}: ${error.cause instanceof Error ? error.cause.message : error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -134,9 +134,10 @@ const layer = Layer.effect(
|
||||
}),
|
||||
Stream.take(input.limit + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
const truncated = rows.length > input.limit
|
||||
if (truncated) return rows.slice(0, input.limit)
|
||||
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
|
||||
|
||||
const code = yield* handle.exitCode
|
||||
const stderr = yield* Fiber.join(stderrFiber)
|
||||
@@ -146,7 +147,7 @@ const layer = Layer.effect(
|
||||
if (code !== 0 && code !== 1 && code !== 2) {
|
||||
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
|
||||
}
|
||||
return code === 1 ? [] : rows
|
||||
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
|
||||
}),
|
||||
)
|
||||
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
|
||||
@@ -177,7 +178,7 @@ const layer = Layer.effect(
|
||||
parse: (line) => Effect.succeed(normalizePath(line)),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
result.map((relative) =>
|
||||
result.items.map((relative) =>
|
||||
Entry.make({
|
||||
path: RelativePath.make(relative),
|
||||
type: "file",
|
||||
@@ -211,7 +212,10 @@ const layer = Layer.effect(
|
||||
)
|
||||
},
|
||||
onItem: input.onEntry,
|
||||
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
|
||||
}).pipe(
|
||||
Effect.map((result) => result.items),
|
||||
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
|
||||
),
|
||||
grep: (input) =>
|
||||
run<RawMatchData>({
|
||||
...input,
|
||||
@@ -244,7 +248,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
result.map((match) =>
|
||||
result.items.map((match) =>
|
||||
Match.make({
|
||||
entry: Entry.make({
|
||||
path: RelativePath.make(match.path.text),
|
||||
|
||||
@@ -393,27 +393,26 @@ export const layer = Layer.effect(
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
return yield* input.resolveModel(input.session).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
onSuccess: (resolved) =>
|
||||
execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
}),
|
||||
}),
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
})
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
|
||||
@@ -131,7 +131,11 @@ const layer = Layer.effect(
|
||||
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
|
||||
})
|
||||
|
||||
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
|
||||
const put = Effect.fn("InstructionEntry.put")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
readonly value: Schema.Json
|
||||
}) {
|
||||
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
|
||||
if (actualBytes > MaxValueBytes)
|
||||
yield* new ValueTooLargeError({
|
||||
@@ -155,7 +159,10 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
|
||||
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
}) {
|
||||
yield* db
|
||||
.update(InstructionEntryTable)
|
||||
.set({ value: null, removed: true, time_updated: Date.now() })
|
||||
|
||||
@@ -43,7 +43,10 @@ const layer = Layer.effect(
|
||||
// are re-discovered and re-injected instead of staying silently lost.
|
||||
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
const claimed = yield* Ref.modify(inFlight, (map) => {
|
||||
const existing = map.get(input.sessionID) ?? new Set<string>()
|
||||
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { DrainResult, Service, type Interface } from "./index.js"
|
||||
import { DrainResult, Service, type Continuation } from "./index.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform.js"
|
||||
@@ -44,7 +44,12 @@ const layer = Layer.effect(
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
|
||||
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
const sessionID = input.sessionID
|
||||
let force = input.force
|
||||
let continuing = input.continuation !== undefined
|
||||
|
||||
@@ -287,7 +287,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
batches(ids.map((row) => row.id)),
|
||||
(batch) =>
|
||||
db
|
||||
.select({ data: EventTable.data })
|
||||
.select({ created: EventTable.created, data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
|
||||
+49
-49
@@ -122,8 +122,8 @@ const layer = () =>
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const commands = new Map<Shell.ID, Active>()
|
||||
const exitOrder: Shell.ID[] = []
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
|
||||
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
@@ -132,44 +132,44 @@ const layer = () =>
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const command of commands.values()) {
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Teardown interrupts pending commands; it is not a terminal command failure.
|
||||
yield* Deferred.interrupt(command.done)
|
||||
yield* Deferred.interrupt(session.done)
|
||||
}
|
||||
commands.clear()
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
const require = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const command = commands.get(id)
|
||||
if (!command) return yield* new NotFoundError({ id })
|
||||
return command
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
})
|
||||
|
||||
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const command = commands.get(id)
|
||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
if (!command) return
|
||||
commands.delete(id)
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock any wait still pending when the command is removed before it terminated.
|
||||
yield* Deferred.fail(command.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||
yield* require(id)
|
||||
yield* removeCommand(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Shell.list")(function* () {
|
||||
return Array.from(commands.values())
|
||||
.filter((command) => command.info.status === "running")
|
||||
.map((command) => command.info)
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.info.status === "running")
|
||||
.map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
||||
@@ -181,24 +181,24 @@ const layer = () =>
|
||||
})
|
||||
|
||||
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
||||
const command = yield* require(id)
|
||||
if (command.info.status !== "running" || !command.timeout) return command.info
|
||||
yield* command.timeout(duration)
|
||||
return command.info
|
||||
const session = yield* require(id)
|
||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||
yield* session.timeout(duration)
|
||||
return session.info
|
||||
})
|
||||
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const command = yield* require(id)
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, command.size - start)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const stream = createReadStream(command.file, { start, end: start + length - 1 })
|
||||
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
||||
let offset = 0
|
||||
stream.on("data", (chunk: string | Buffer) => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
@@ -212,7 +212,7 @@ const layer = () =>
|
||||
return {
|
||||
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
cursor: start + bytesRead,
|
||||
size: command.size,
|
||||
size: session.size,
|
||||
truncated: false,
|
||||
}
|
||||
})
|
||||
@@ -257,7 +257,7 @@ const layer = () =>
|
||||
|
||||
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
|
||||
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
||||
// end). `create` returns once `ready` resolves with the registered command.
|
||||
// end). `create` returns once `ready` resolves with the registered session.
|
||||
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
|
||||
runFork(
|
||||
Effect.scoped(
|
||||
@@ -275,7 +275,7 @@ const layer = () =>
|
||||
.pipe(
|
||||
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
|
||||
)
|
||||
const command: Active = {
|
||||
const session: Active = {
|
||||
info: produce(info, (draft) => {
|
||||
draft.pid = handle.pid
|
||||
}),
|
||||
@@ -283,7 +283,7 @@ const layer = () =>
|
||||
size: 0,
|
||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||
}
|
||||
commands.set(id, command)
|
||||
sessions.set(id, session)
|
||||
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Latch.makeUnsafe()
|
||||
@@ -291,7 +291,7 @@ const layer = () =>
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
stream.write(chunk)
|
||||
command.size += chunk.length
|
||||
session.size += chunk.length
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -317,8 +317,8 @@ const layer = () =>
|
||||
|
||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||
Effect.gen(function* () {
|
||||
if (command.info.status !== "running") return
|
||||
command.info = produce(command.info, (draft) => {
|
||||
if (session.info.status !== "running") return
|
||||
session.info = produce(session.info, (draft) => {
|
||||
draft.status = status
|
||||
if (exit !== undefined) draft.exit = exit
|
||||
draft.time.completed = Date.now()
|
||||
@@ -326,10 +326,10 @@ const layer = () =>
|
||||
yield* beforeWait
|
||||
yield* outputDone.await
|
||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||
// command still reports success rather than the removal NotFoundError. This runs before
|
||||
// session still reports success rather than the removal NotFoundError. This runs before
|
||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
||||
yield* Deferred.succeed(command.done, command.info)
|
||||
yield* Deferred.succeed(session.done, session.info)
|
||||
yield* bus.publish(Shell.Event.Exited, {
|
||||
id,
|
||||
...(exit !== undefined ? { exit } : {}),
|
||||
@@ -339,19 +339,19 @@ const layer = () =>
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeCommand(oldest)
|
||||
yield* removeSession(Shell.ID.make(oldest))
|
||||
}
|
||||
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
||||
// aborting finish when finish itself runs on the timeout fiber.
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
})
|
||||
|
||||
command.timeout = (duration) =>
|
||||
session.timeout = (duration) =>
|
||||
Effect.gen(function* () {
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
command.timeoutFiber = undefined
|
||||
if (duration === 0 || command.info.status !== "running") return
|
||||
command.timeoutFiber = runFork(
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
session.timeoutFiber = undefined
|
||||
if (duration === 0 || session.info.status !== "running") return
|
||||
session.timeoutFiber = runFork(
|
||||
Effect.sleep(Duration.millis(duration)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||
@@ -360,7 +360,7 @@ const layer = () =>
|
||||
)
|
||||
})
|
||||
|
||||
yield* command.timeout(invocation.timeout)
|
||||
yield* session.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
@@ -370,16 +370,16 @@ const layer = () =>
|
||||
)
|
||||
|
||||
yield* bus.publish(Shell.Event.Created, { info })
|
||||
yield* Deferred.succeed(ready, command)
|
||||
yield* Deferred.succeed(ready, session)
|
||||
// Hold the handle's scope open until the command terminates; closing it earlier would
|
||||
// release (kill) the process before its exit is observed.
|
||||
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
|
||||
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
|
||||
)
|
||||
|
||||
const command = yield* Deferred.await(ready)
|
||||
return command.info
|
||||
const session = yield* Deferred.await(ready)
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
|
||||
@@ -72,7 +72,6 @@ export const layer = Layer.effect(
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
sessionID: context.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
|
||||
@@ -375,100 +375,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes file data across AI SDK prompt parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("opaque-provider"))
|
||||
const bytes = new Uint8Array([0, 1, 2, 3])
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: bytes, filename: "bytes.png" },
|
||||
{ type: "media", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
|
||||
{
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "data:image/png;charset=utf-8;base64,AQID",
|
||||
filename: "inline.png",
|
||||
},
|
||||
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
|
||||
{ type: "media", mediaType: "image/png", data: "s3://bucket/image.png" },
|
||||
]),
|
||||
Message.assistant({
|
||||
type: "media",
|
||||
mediaType: "application/pdf",
|
||||
data: "http://example.com/document.pdf",
|
||||
filename: "document.pdf",
|
||||
}),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "screenshot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,BAUG", mime: "image/png", name: "tool.png" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "file", mediaType: "image/png", data: bytes, filename: "bytes.png" },
|
||||
{ type: "file", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
|
||||
{ type: "file", mediaType: "image/png", data: "AQID", filename: "inline.png" },
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "image/png",
|
||||
data: new URL("https://example.com/image.png"),
|
||||
filename: undefined,
|
||||
},
|
||||
{ type: "file", mediaType: "image/png", data: "s3://bucket/image.png", filename: undefined },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "application/pdf",
|
||||
data: new URL("http://example.com/document.pdf"),
|
||||
filename: "document.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "screenshot",
|
||||
output: { type: "text", value: "Media attached in the following user message." },
|
||||
providerOptions: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "file", mediaType: "image/png", data: "BAUG", filename: "tool.png" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes user and tool media through the real Mistral provider", () =>
|
||||
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let body: { messages?: unknown[] } | undefined
|
||||
@@ -508,14 +415,7 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Inspect the attachments." },
|
||||
{ type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) },
|
||||
{ type: "media", mediaType: "image/png", data: "AQID" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" },
|
||||
{ type: "media", mediaType: "image/png", data: "http://example.com/image.png" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
|
||||
]),
|
||||
Message.user("Inspect the screenshot."),
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
|
||||
Message.tool({
|
||||
type: "tool-result",
|
||||
@@ -526,12 +426,6 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
value: [
|
||||
{ type: "text", text: "Screenshot captured" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "https://example.com/tool-document.pdf",
|
||||
mime: "application/pdf",
|
||||
name: "tool-document.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -540,17 +434,7 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
).pipe(Effect.provide(client))
|
||||
|
||||
expect(body?.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect the attachments." },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,AAECAw==" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,AQID" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,BAUG" },
|
||||
{ type: "image_url", image_url: "http://example.com/image.png" },
|
||||
{ type: "document_url", document_url: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
@@ -573,7 +457,6 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
|
||||
{ type: "document_url", document_url: "https://example.com/tool-document.pdf" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -85,8 +85,8 @@ describe("Bus Session routing", () => {
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(yield* Fiber.join(first)).toEqual([moved, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([moved, after, same, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
|
||||
expect(moved.location).toEqual(a)
|
||||
}),
|
||||
)
|
||||
@@ -130,8 +130,8 @@ describe("Bus Session routing", () => {
|
||||
)
|
||||
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -158,17 +158,19 @@ describe("Bus Session routing", () => {
|
||||
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
|
||||
const done = yield* bus.publish(Done, {})
|
||||
|
||||
expect(yield* Fiber.join(first)).toEqual([renamed, text, broadcast, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([broadcast, explicit, done])
|
||||
expect(yield* Fiber.join(workspace)).toEqual([broadcast, done])
|
||||
expect(yield* Fiber.join(global)).toEqual([renamed, text, broadcast, explicit, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
|
||||
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
|
||||
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
|
||||
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
|
||||
expect(renamed).not.toHaveProperty("location")
|
||||
expect(text).not.toHaveProperty("location")
|
||||
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
|
||||
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
|
||||
expect(
|
||||
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
|
||||
Array.from(history)
|
||||
.filter((event): event is Event.Payload => !Bus.isSynced(event))
|
||||
.every((event) => !event.location),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -195,8 +197,8 @@ describe("Bus Session routing", () => {
|
||||
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
|
||||
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(yield* Fiber.join(typed)).toEqual([expected])
|
||||
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
|
||||
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
|
||||
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -224,8 +226,8 @@ describe("Bus Session routing", () => {
|
||||
const done = yield* bus.publish(Done, {})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
|
||||
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
|
||||
expect(moved).not.toHaveProperty("location")
|
||||
}),
|
||||
)
|
||||
@@ -243,9 +245,9 @@ describe("Bus Session routing", () => {
|
||||
|
||||
const database = yield* Database.Service
|
||||
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
|
||||
expect(yield* Fiber.join(first)).toEqual([deleted, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
|
||||
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -264,8 +266,8 @@ describe("Bus Session routing", () => {
|
||||
])
|
||||
const done = yield* bus.publish(Done, {})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
|
||||
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -293,8 +295,8 @@ describe("Bus Session routing", () => {
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(Exit.isFailure(single)).toBe(true)
|
||||
expect(Exit.isFailure(batch)).toBe(true)
|
||||
expect(yield* Fiber.join(first)).toEqual([before, after, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -325,8 +327,8 @@ describe("Bus Session routing", () => {
|
||||
{ publish: true },
|
||||
)
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(yield* Fiber.join(first)).toEqual([done])
|
||||
const received = yield* Fiber.join(second)
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
|
||||
const received = Array.from(yield* Fiber.join(second))
|
||||
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
|
||||
expect(received[1]).not.toHaveProperty("location")
|
||||
}),
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(Message, { text: "hello" })
|
||||
yield* bus.publish(CountMessage, { count: 2 })
|
||||
|
||||
const received = (yield* Fiber.join(fiber)).map((event) =>
|
||||
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
|
||||
event.type === "test.message" ? event.data.text : event.data.count,
|
||||
)
|
||||
expect(received).toEqual(["hello", 2])
|
||||
@@ -136,7 +136,7 @@ describe("Bus", () => {
|
||||
const fiber = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* bus.publish(Message, { text: "hello" })
|
||||
const received = yield* Fiber.join(fiber)
|
||||
const received = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(received).toEqual([event])
|
||||
expect(event.type).toBe("test.message")
|
||||
@@ -212,8 +212,8 @@ describe("Bus", () => {
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* bus.publish(Message, { text: "hello" })
|
||||
|
||||
expect(yield* Fiber.join(typed)).toEqual([event])
|
||||
expect(yield* Fiber.join(wildcard)).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -602,7 +602,7 @@ describe("Bus", () => {
|
||||
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
[1, durableData(aggregateID, "one")],
|
||||
[2, durableData(aggregateID, "two")],
|
||||
])
|
||||
@@ -618,7 +618,7 @@ describe("Bus", () => {
|
||||
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
[0, durableData(aggregateID, "zero")],
|
||||
[1, durableData(aggregateID, "one")],
|
||||
])
|
||||
@@ -653,7 +653,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
|
||||
yield* Deferred.succeed(continueRead, undefined)
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
[0, durableData(aggregateID, "during handoff")],
|
||||
])
|
||||
}).pipe(Effect.provide(eventLayer))
|
||||
@@ -672,7 +672,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
|
||||
}
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
|
||||
Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
|
||||
)
|
||||
}),
|
||||
@@ -688,7 +688,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(Message, { text: "live only" })
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1268,7 +1268,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
|
||||
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
@@ -1284,9 +1284,9 @@ describe("Bus", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
|
||||
const empty = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
const drained = yield* Stream.runCollect(bus.log({ aggregateID, after: 0 }))
|
||||
const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
|
||||
|
||||
expect(empty).toEqual([{ type: "log.synced", aggregateID }])
|
||||
expect(empty[0]).not.toHaveProperty("seq")
|
||||
@@ -1306,7 +1306,7 @@ describe("Bus", () => {
|
||||
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
const items = yield* Fiber.join(fiber)
|
||||
const items = Array.from(yield* Fiber.join(fiber))
|
||||
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
|
||||
@@ -1330,7 +1330,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
|
||||
|
||||
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
|
||||
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
@@ -1378,7 +1378,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
yield* Deferred.succeed(releaseRead, undefined)
|
||||
|
||||
const items = yield* Fiber.join(fiber)
|
||||
const items = Array.from(yield* Fiber.join(fiber))
|
||||
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
|
||||
|
||||
@@ -84,8 +84,7 @@ describe("Catalog", () => {
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = integrationID
|
||||
@@ -93,8 +92,7 @@ describe("Catalog", () => {
|
||||
)
|
||||
expect(yield* catalog.provider.available()).toEqual([])
|
||||
|
||||
const credentials = yield* Credential.Service
|
||||
yield* credentials.create({
|
||||
yield* (yield* Credential.Service).create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "secret" }),
|
||||
})
|
||||
@@ -114,8 +112,7 @@ describe("Catalog", () => {
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = integrationID
|
||||
|
||||
@@ -330,7 +330,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("loads legacy file-based agents from config directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -404,7 +407,10 @@ Use native v2 fields.`,
|
||||
|
||||
for (const testCase of sourceCases()) {
|
||||
it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, testCase.source)
|
||||
@@ -439,7 +445,10 @@ Use native v2 fields.`,
|
||||
}
|
||||
|
||||
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "agents")
|
||||
@@ -476,7 +485,10 @@ Use native v2 fields.`,
|
||||
)
|
||||
|
||||
it.effect("ignores updates outside agent source directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "agents")
|
||||
|
||||
@@ -54,7 +54,10 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -163,7 +166,10 @@ Review files`,
|
||||
|
||||
for (const testCase of sourceCases()) {
|
||||
it.effect(`rebuilds commands when a source file is ${testCase.name}`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
@@ -206,7 +212,10 @@ Review files`,
|
||||
}
|
||||
|
||||
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
@@ -245,7 +254,10 @@ Review files`,
|
||||
)
|
||||
|
||||
it.effect("ignores updates outside command source directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
@@ -77,7 +77,10 @@ const provider = {
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("excludes home-level claude and agents directories when global is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(global, "home")
|
||||
@@ -117,7 +120,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("excludes global config reached through the project walk when global is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
// The location sits BENEATH the global config dir, so the upward walk
|
||||
// reaches the global opencode.json as a direct file.
|
||||
@@ -150,7 +156,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads explicit file and content overrides in priority order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
@@ -185,7 +194,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("skips project configuration when project discovery is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
@@ -213,7 +225,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -246,7 +261,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("exposes filesystem updates under config roots through changes", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -278,7 +296,10 @@ describe("Config", () => {
|
||||
// watch being torn down, making recreation invisible) only reproduces with
|
||||
// path-faithful event delivery.
|
||||
it.live("keeps watching a deleted config file so recreating it reloads", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -348,24 +369,26 @@ describe("Config", () => {
|
||||
}).pipe(Effect.provide(Config.testLayer())),
|
||||
)
|
||||
|
||||
test("returns the latest defined scalar from priority-ordered documents", () => {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Document({ type: "document", info: new Info({}) }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Document({ type: "document", info: new Info({}) }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
|
||||
expect(Config.latest(entries, "default_agent")).toBeUndefined()
|
||||
})
|
||||
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
|
||||
expect(Config.latest(entries, "default_agent")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -557,241 +580,268 @@ describe("Config", () => {
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
}, 30_000)
|
||||
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates the v1 experimental subagent depth", () => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
})
|
||||
it.effect("migrates the v1 experimental subagent depth", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates the v1 small model to the title agent", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
}).agents?.title,
|
||||
).toEqual({
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
})
|
||||
})
|
||||
it.effect("migrates the v1 small model to the title agent", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
}).agents?.title,
|
||||
).toEqual({
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates v1 provider lists to policies", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
enabled_providers: ["anthropic", "openai"],
|
||||
disabled_providers: ["openai"],
|
||||
}).experimental?.policies,
|
||||
).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||
])
|
||||
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
])
|
||||
})
|
||||
it.effect("migrates v1 provider lists to policies", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
enabled_providers: ["anthropic", "openai"],
|
||||
disabled_providers: ["openai"],
|
||||
}).experimental?.policies,
|
||||
).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||
])
|
||||
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates v1 provider setup options into AISDK settings", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
|
||||
options: {
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
|
||||
options: {
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(migrated.providers?.bedrock).toMatchObject({
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
})
|
||||
})
|
||||
expect(migrated.providers?.bedrock).toMatchObject({
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("renames old provider IDs while migrating v1 configuration", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
model: "azure-cognitive-services/deployment",
|
||||
enabled_providers: ["google-vertex-anthropic"],
|
||||
disabled_providers: ["azure-cognitive-services"],
|
||||
agent: {
|
||||
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
|
||||
},
|
||||
command: {
|
||||
review: { template: "Review", model: "azure-cognitive-services/deployment" },
|
||||
},
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/azure",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
models: { deployment: {} },
|
||||
it.effect("renames old provider IDs while migrating v1 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
model: "azure-cognitive-services/deployment",
|
||||
enabled_providers: ["google-vertex-anthropic"],
|
||||
disabled_providers: ["azure-cognitive-services"],
|
||||
agent: {
|
||||
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
|
||||
},
|
||||
"google-vertex-anthropic": {
|
||||
npm: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { project: "test-project", location: "us-central1" },
|
||||
models: { "claude-sonnet": {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
|
||||
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
|
||||
{ action: "provider.use", resource: "azure", effect: "deny" },
|
||||
])
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/azure"),
|
||||
models: { deployment: {} },
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||
},
|
||||
})
|
||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: {
|
||||
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores old provider IDs when the current provider ID is configured", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
azure: { models: { current: {} } },
|
||||
"azure-cognitive-services": { models: { legacy: {} } },
|
||||
"google-vertex": { models: { gemini: {} } },
|
||||
"google-vertex-anthropic": { models: { claude: {} } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
|
||||
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
|
||||
})
|
||||
|
||||
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"google-vertex-anthropic": {
|
||||
models: { claude: {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
|
||||
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
|
||||
)
|
||||
})
|
||||
|
||||
test("migrates v1 interleaved fields to compatibility", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
custom: {
|
||||
models: {
|
||||
object: { interleaved: { field: "vendor_reasoning" } },
|
||||
string: { interleaved: "reasoning_text" },
|
||||
boolean: { interleaved: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
})
|
||||
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates v1 command configuration", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
review: { template: "Review", model: "azure-cognitive-services/deployment" },
|
||||
},
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/azure",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
models: { deployment: {} },
|
||||
},
|
||||
"google-vertex-anthropic": {
|
||||
npm: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { project: "test-project", location: "us-central1" },
|
||||
models: { "claude-sonnet": {} },
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes renamed permission actions when migrating v1 permissions", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
permission: {
|
||||
task: "ask",
|
||||
bash: { "git status": "allow", "*": "deny" },
|
||||
write: "deny",
|
||||
read: "allow",
|
||||
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
|
||||
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
|
||||
{ action: "provider.use", resource: "azure", effect: "deny" },
|
||||
])
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/azure"),
|
||||
models: { deployment: {} },
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||
},
|
||||
}).permissions,
|
||||
).toEqual([
|
||||
{ action: "subagent", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
])
|
||||
})
|
||||
})
|
||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: {
|
||||
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
azure: { models: { current: {} } },
|
||||
"azure-cognitive-services": { models: { legacy: {} } },
|
||||
"google-vertex": { models: { gemini: {} } },
|
||||
"google-vertex-anthropic": { models: { claude: {} } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
|
||||
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"google-vertex-anthropic": {
|
||||
models: { claude: {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
|
||||
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 interleaved fields to compatibility", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
custom: {
|
||||
models: {
|
||||
object: { interleaved: { field: "vendor_reasoning" } },
|
||||
string: { interleaved: "reasoning_text" },
|
||||
boolean: { interleaved: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
})
|
||||
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 command configuration", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
permission: {
|
||||
task: "ask",
|
||||
bash: { "git status": "allow", "*": "deny" },
|
||||
write: "deny",
|
||||
read: "allow",
|
||||
},
|
||||
}).permissions,
|
||||
).toEqual([
|
||||
{ action: "subagent", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns an empty configuration when directory files do not exist", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
@@ -806,7 +856,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -835,7 +888,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -863,7 +919,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -974,7 +1033,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("does not load legacy config.json files", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -993,7 +1055,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("accepts $schema metadata without writing it into config files", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
@@ -1017,7 +1082,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads supported scalar and resource configuration", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1203,7 +1271,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("migrates the deprecated reference key into references", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1236,7 +1307,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("migrates v1 configuration when a v1-only key is present", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1408,7 +1482,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("ignores an invalid file while loading valid config values", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1434,7 +1511,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads global and ancestor configuration across the project boundary", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const root = path.join(tmp.path, "repo")
|
||||
|
||||
@@ -2,15 +2,13 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
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 { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
@@ -20,7 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
|
||||
import { Effect, Fiber, Logger, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -37,40 +35,6 @@ const staticIt = testEffect(
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const refreshNpm = makeGlobalNode({
|
||||
service: Npm.Service,
|
||||
layer: Layer.effect(
|
||||
Npm.Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "background-refresh-plugin")
|
||||
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
|
||||
return Npm.Service.of({
|
||||
add: (_pkg, options) =>
|
||||
options?.refresh
|
||||
? Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
|
||||
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
|
||||
return installed
|
||||
})
|
||||
: Effect.succeed(installed),
|
||||
resolve: () => Effect.succeed(installed),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}),
|
||||
),
|
||||
deps: [Global.node],
|
||||
})
|
||||
const refreshIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Npm.node, refreshNpm],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("applies selectors in order", () =>
|
||||
@@ -87,6 +51,7 @@ describe("PluginSupervisor config", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows the built-in Plan agent to be disabled", () =>
|
||||
withLocation(
|
||||
{ agents: { plan: { disabled: true } } },
|
||||
@@ -305,7 +270,7 @@ describe("PluginSupervisor config", () => {
|
||||
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
@@ -415,7 +380,7 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("loads user plugins before internal post plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
@@ -466,8 +431,8 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("unblocks flush when plugin activation fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
@@ -476,47 +441,6 @@ describe("PluginSupervisor config", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "background-refresh-plugin")
|
||||
const activated = path.join(directory, "activated")
|
||||
const release = path.join(directory, "release")
|
||||
const refreshed = path.join(directory, "refresh-requested")
|
||||
const refreshRelease = path.join(directory, "refresh-release")
|
||||
const refreshFinished = path.join(directory, "refresh-finished")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(directory, "index.js"),
|
||||
`export default {
|
||||
id: "background-refresh-plugin",
|
||||
async setup() {
|
||||
await Bun.write(${JSON.stringify(activated)}, "")
|
||||
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
|
||||
},
|
||||
}`,
|
||||
)
|
||||
})
|
||||
|
||||
yield* withLocation(
|
||||
{ plugins: ["background-refresh-plugin"] },
|
||||
Effect.gen(function* () {
|
||||
yield* waitForFile(activated)
|
||||
yield* Effect.sleep("100 millis")
|
||||
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
|
||||
yield* Effect.promise(() => Bun.write(release, ""))
|
||||
yield* waitForFile(refreshed)
|
||||
yield* ready().pipe(Effect.timeout("2 seconds"))
|
||||
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
|
||||
yield* waitForFile(refreshFinished)
|
||||
const plugins = yield* Plugin.Service
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
@@ -524,20 +448,16 @@ const ready = Effect.fnUntraced(function* () {
|
||||
yield* supervisor.flush
|
||||
})
|
||||
|
||||
const waitForFile = (file: string) =>
|
||||
Effect.promise(() => Bun.file(file).exists()).pipe(
|
||||
Effect.filterOrFail((exists) => exists),
|
||||
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
|
||||
function withLocation<A, E, R>(
|
||||
config: unknown,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
fixtures = false,
|
||||
prepare?: (directory: string) => Promise<void>,
|
||||
) {
|
||||
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.tap((tmp) =>
|
||||
Effect.promise(async () => {
|
||||
await prepare?.(tmp.path)
|
||||
|
||||
@@ -34,41 +34,6 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
it.effect("preserves reference precedence and insertion order across documents", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const references = yield* Reference.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* references.transform((draft) =>
|
||||
draft.add(
|
||||
"external",
|
||||
Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/references/external") }),
|
||||
),
|
||||
)
|
||||
yield* ConfigReferencePlugin.Plugin.effect(host)
|
||||
|
||||
const result = yield* references.list()
|
||||
expect(result.map((reference) => reference.name)).toEqual(["external", "shared", "first", "second"])
|
||||
expect(result.find((reference) => reference.name === "shared")?.path).toBe(
|
||||
AbsolutePath.make(path.resolve("/config/second/shared")),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
referenceConfig("/config/first/opencode.json", {
|
||||
shared: "./shared",
|
||||
first: "./first",
|
||||
}),
|
||||
referenceConfig("/config/second/opencode.json", {
|
||||
shared: "./shared",
|
||||
second: "./second",
|
||||
}),
|
||||
]),
|
||||
),
|
||||
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads config-backed domains without reloading external plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
@@ -137,14 +102,6 @@ function config(name: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function referenceConfig(file: string, references: Record<string, string>) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
path: AbsolutePath.make(file),
|
||||
info: decode({ references }),
|
||||
})
|
||||
}
|
||||
|
||||
function title(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,6 @@ const users = sqliteTable("users", {
|
||||
id: integer().primaryKey({ autoIncrement: true }),
|
||||
name: text().notNull(),
|
||||
})
|
||||
const teams = sqliteTable("teams", {
|
||||
id: integer().primaryKey(),
|
||||
name: text().notNull(),
|
||||
})
|
||||
const memberships = sqliteTable("memberships", {
|
||||
user_id: integer().notNull(),
|
||||
team_id: integer().notNull(),
|
||||
})
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
|
||||
Effect.runPromise(
|
||||
@@ -171,41 +163,3 @@ test("supports returning and rejects empty update sets", async () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("supports function-valued update joins with runtime table columns", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
const query = db
|
||||
.update(users)
|
||||
.set({ name: "Grace" })
|
||||
.from(teams)
|
||||
.innerJoin(memberships, (update) => eq(update.id, memberships.user_id))
|
||||
.where(eq(teams.name, "Core"))
|
||||
|
||||
expect(query.toSQL()).toEqual({
|
||||
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
|
||||
params: ["Grace", "Core"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("supports SQL-valued update joins", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
const query = db
|
||||
.update(users)
|
||||
.set({ name: "Lin" })
|
||||
.from(teams)
|
||||
.innerJoin(memberships, eq(users.id, memberships.user_id))
|
||||
.where(eq(teams.name, "Core"))
|
||||
|
||||
expect(query.toSQL()).toEqual({
|
||||
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
|
||||
params: ["Lin", "Core"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,18 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
|
||||
}
|
||||
|
||||
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
|
||||
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
|
||||
return Stream.runCollect(stream).pipe(
|
||||
Effect.map((chunks) => {
|
||||
const total = chunks.reduce((acc, x) => acc + x.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, off)
|
||||
off += chunk.length
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(out).trim()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function alive(pid: number) {
|
||||
|
||||
@@ -201,8 +201,7 @@ describe("layer node", () => {
|
||||
Layer.provide(LayerNode.compile(result.hoisted)),
|
||||
) as unknown as Layer.Layer<App>
|
||||
const program = Effect.gen(function* () {
|
||||
const app = yield* App
|
||||
return yield* app.run
|
||||
return yield* (yield* App).run
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
expect(await Effect.runPromise(program)).toEqual(["Alice"])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, spyOn } from "bun:test"
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
@@ -14,8 +14,6 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
|
||||
Layer.succeed(
|
||||
@@ -34,13 +32,14 @@ const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void)
|
||||
)
|
||||
|
||||
describe("FileSystemSearch", () => {
|
||||
it.live("honors wildcard directory rules from .gitignore", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-fff-ignore-")))).path
|
||||
yield* Effect.promise(() => mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, ".gitignore"), "**/target/\n"))
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored"))
|
||||
expect(Bun.spawnSync(["git", "init", "-q"], { cwd: directory }).exitCode).toBe(0)
|
||||
test("honors wildcard directory rules from .gitignore", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
|
||||
try {
|
||||
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
|
||||
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
|
||||
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
|
||||
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
|
||||
expect(git.exitCode).toBe(0)
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const layer = FileSystemSearch.fffLayer.pipe(
|
||||
@@ -55,22 +54,26 @@ describe("FileSystemSearch", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "target" })
|
||||
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
const entries = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
return yield* search.find({ query: "target" })
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-search-workspace-"))))
|
||||
.path
|
||||
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
|
||||
try {
|
||||
// A local file that only an fff index of the server directory could surface.
|
||||
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
|
||||
// true; elsewhere the layer choice already falls back to ripgrep.
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "server-local.ts"), "server local"))
|
||||
await Bun.write(path.join(directory, "server-local.ts"), "server local")
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make(directory),
|
||||
@@ -89,35 +92,37 @@ describe("FileSystemSearch", () => {
|
||||
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "ts", type: "file" })
|
||||
expect(observed?.cwd).toBe(directory)
|
||||
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "ts", type: "file" })
|
||||
expect(observed?.cwd).toBe(directory)
|
||||
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.live("bounds a home scan even when home is detected as a repository", () =>
|
||||
Effect.gen(function* () {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: home },
|
||||
{ vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } },
|
||||
),
|
||||
),
|
||||
test("bounds a home scan even when home is detected as a repository", async () => {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(observed).toBeUndefined()
|
||||
@@ -127,52 +132,52 @@ describe("FileSystemSearch", () => {
|
||||
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
|
||||
RelativePath.make(`src${path.sep}`),
|
||||
)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("refreshes a stale ripgrep index atomically without blocking search", () =>
|
||||
Effect.gen(function* () {
|
||||
let scans = 0
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||
let scans = 0
|
||||
const started = Effect.runSync(Deferred.make<void>())
|
||||
const release = Effect.runSync(Deferred.make<void>())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* search.find({ query: "old", type: "file" })
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
@@ -191,53 +196,47 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
|
||||
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
|
||||
expect(scans).toBe(2)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("reuses location-owned fuzzy targets across index refreshes", () =>
|
||||
Effect.gen(function* () {
|
||||
let scans = 0
|
||||
const second = yield* Deferred.make<void>()
|
||||
const prepare = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => spyOn(fuzzysort, "prepare")),
|
||||
(value) => Effect.sync(() => value.mockRestore()),
|
||||
)
|
||||
const cleanup = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => spyOn(fuzzysort, "cleanup")),
|
||||
(value) => Effect.sync(() => value.mockRestore()),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
test("reuses location-owned fuzzy targets across index refreshes", async () => {
|
||||
let scans = 0
|
||||
const second = Effect.runSync(Deferred.make<void>())
|
||||
const prepare = spyOn(fuzzysort, "prepare")
|
||||
const cleanup = spyOn(fuzzysort, "cleanup")
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans > 1) yield* Deferred.succeed(second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans > 1) yield* Deferred.succeed(second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
@@ -247,7 +246,9 @@ describe("FileSystemSearch", () => {
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(2)
|
||||
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
prepare.mockRestore()
|
||||
cleanup.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,10 @@ 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, PluginSupervisor.Service.of({ flush: Effect.void })),
|
||||
layer: Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
@@ -313,7 +316,7 @@ describe("LocationWatcher subscriptions", () => {
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* policy.transform((draft) => draft.add([".git"]))
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void })
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })
|
||||
}),
|
||||
),
|
||||
deps: [LocationWatcherPolicy.node],
|
||||
|
||||
@@ -12,7 +12,10 @@ export const promptLocationLayer = Layer.effect(
|
||||
() =>
|
||||
Layer.merge(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.void,
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
}),
|
||||
) as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -244,25 +244,14 @@ describe("doStream", () => {
|
||||
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
|
||||
|
||||
// In this fixture, reasoning_opaque comes AFTER content has started (in chunk 4)
|
||||
// So it arrives too late to be attached to reasoning-end. It should still be
|
||||
// captured on the completed text part and the finish event.
|
||||
// So it arrives too late to be attached to reasoning-end. But it should still
|
||||
// be captured and included in the finish event's providerMetadata.
|
||||
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
|
||||
expect(reasoningEnd).toMatchObject({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
})
|
||||
|
||||
const textEnd = parts.find((p) => p.type === "text-end")
|
||||
expect(textEnd).toEqual({
|
||||
type: "text-end",
|
||||
id: "txt-0",
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
reasoningOpaque: "/PMlTqxqSJZnUBDHgnnJKLVI4eZQ",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// reasoning_opaque should be in the finish event's providerMetadata
|
||||
const finish = parts.find((p) => p.type === "finish")
|
||||
expect(finish).toMatchObject({
|
||||
@@ -316,17 +305,6 @@ describe("doStream", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const textEnd = parts.find((p) => p.type === "text-end")
|
||||
expect(textEnd).toEqual({
|
||||
type: "text-end",
|
||||
id: "txt-0",
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
reasoningOpaque: "ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Check text deltas
|
||||
const textDeltas = parts.filter((p) => p.type === "text-delta")
|
||||
expect(textDeltas).toHaveLength(2)
|
||||
|
||||
@@ -186,7 +186,7 @@ describe("Integration", () => {
|
||||
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
|
||||
}),
|
||||
])
|
||||
expect((yield* Fiber.join(created)).map((event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
|
||||
])
|
||||
|
||||
@@ -49,29 +49,6 @@ export const environmentConformance = <E>(
|
||||
}),
|
||||
)
|
||||
|
||||
check("observes filesystem state when an operation executes", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/deferred.txt`
|
||||
const source = `${harness.root}/source.txt`
|
||||
const destination = `${harness.root}/destination.txt`
|
||||
const read = harness.files.read(target)
|
||||
const stat = harness.files.stat(target)
|
||||
const list = harness.files.list(harness.root)
|
||||
const move = harness.files.move(source, destination)
|
||||
|
||||
yield* harness.files.write(target, bytes("first"))
|
||||
yield* harness.files.write(source, bytes("moved"))
|
||||
expect(text((yield* read).bytes)).toBe("first")
|
||||
expect((yield* stat).size).toBe(5)
|
||||
expect(yield* list).toContainEqual({ name: "deferred.txt", type: "file" })
|
||||
yield* move
|
||||
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
|
||||
|
||||
yield* harness.files.write(target, bytes("second"))
|
||||
expect(text((yield* read).bytes)).toBe("second")
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports missing paths", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/missing`
|
||||
|
||||
@@ -51,8 +51,7 @@ describe("FileSystem", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const entries = yield* filesystem.list()
|
||||
const entries = yield* (yield* FileSystem.Service).list()
|
||||
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||
{ path: RelativePath.make("src" + path.sep), type: "directory" },
|
||||
{ path: RelativePath.make("README.md"), type: "file" },
|
||||
@@ -104,8 +103,9 @@ describe("FileSystem", () => {
|
||||
it.live("rejects lexical escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
||||
const result = yield* (yield* FileSystem.Service)
|
||||
.read({ path: RelativePath.make("../outside.txt") })
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Hash,
|
||||
Layer,
|
||||
LayerMap,
|
||||
Option,
|
||||
RcMap,
|
||||
Schema,
|
||||
Stream,
|
||||
@@ -518,8 +517,7 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
const plugins = yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* (yield* PluginSupervisor.Service).flush
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
@@ -676,21 +674,14 @@ describe("LocationServiceMap", () => {
|
||||
expect(Equal.equals(absent, present)).toBe(false)
|
||||
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
|
||||
|
||||
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
|
||||
|
||||
const first = yield* locations.contextEffect(absent)
|
||||
expect(yield* locations.contextEffect(present)).toBe(first)
|
||||
expect(Option.getOrThrow(yield* locations.contextEffectOption(absent))).toBe(first)
|
||||
expect(Option.getOrThrow(yield* locations.contextEffectOption(present))).toBe(first)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
|
||||
Location.Ref.make({ directory, workspaceID: undefined }),
|
||||
])
|
||||
|
||||
// Invalidating with the shape opposite to the one that booted must evict.
|
||||
yield* locations.invalidate(present)
|
||||
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
|
||||
expect(yield* locations.contextEffectOption(present)).toEqual(Option.none())
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
|
||||
}),
|
||||
),
|
||||
@@ -943,8 +934,7 @@ describe("LocationServiceMap", () => {
|
||||
})
|
||||
yield* plugins.activate([{ ...reviewer, version: "1" }])
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
|
||||
expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
|
||||
description: "Reviews code",
|
||||
mode: "subagent",
|
||||
})
|
||||
|
||||
@@ -43,8 +43,7 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "hello.txt" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
@@ -59,8 +58,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
@@ -72,8 +70,7 @@ describe("LocationMutation", () => {
|
||||
it.live("requires external-directory authorization for a relative lexical escape", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "../outside.txt" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const root = path.dirname(directory)
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "outside.txt"),
|
||||
@@ -120,8 +117,7 @@ describe("LocationMutation", () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "escape", "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
@@ -141,8 +137,7 @@ describe("LocationMutation", () => {
|
||||
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
|
||||
})
|
||||
|
||||
const mutation = yield* LocationMutation.Service
|
||||
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
absolute: path.join(directory, "linked", "new.txt"),
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
@@ -154,8 +149,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
resource: "new.txt",
|
||||
@@ -170,8 +164,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = outside
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "new.txt"),
|
||||
@@ -192,8 +185,7 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({ absolute: targetPath })
|
||||
expect(target.externalDirectory?.directory).toBe(outside)
|
||||
}).pipe(provide(directory)),
|
||||
@@ -205,8 +197,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: outside, kind: "file" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: path.dirname(outside),
|
||||
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
|
||||
@@ -221,8 +212,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const parent = path.dirname(targetPath)
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: parent,
|
||||
@@ -264,8 +254,7 @@ describe("LocationMutation", () => {
|
||||
it.live("resolves a tilde path as an external home target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "~/notes.md" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
|
||||
const absolute = path.resolve(Global.Path.home, "notes.md")
|
||||
expect(target).toMatchObject({
|
||||
absolute,
|
||||
@@ -281,8 +270,7 @@ describe("LocationMutation", () => {
|
||||
|
||||
it.live("treats a tilde path as in-location when the location is home", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "~/notes.md" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.resolve(Global.Path.home, "notes.md"),
|
||||
resource: "notes.md",
|
||||
|
||||
@@ -49,7 +49,6 @@ import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/t
|
||||
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, Permission.Error> = Effect.void
|
||||
let calls = 0
|
||||
let invocations: Array<Parameters<Mcp.Interface["callTool"]>[0]> = []
|
||||
|
||||
type ResourcePage = {
|
||||
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
|
||||
@@ -84,12 +83,6 @@ function resourceServer(
|
||||
resourceLists: 0,
|
||||
templateLists: 0,
|
||||
toolLists: 0,
|
||||
toolCalls: [] as Array<{
|
||||
name: string
|
||||
arguments: Record<string, unknown> | undefined
|
||||
sessionID: unknown
|
||||
progressToken: unknown
|
||||
}>,
|
||||
initializations: 0,
|
||||
urls: [] as string[],
|
||||
}
|
||||
@@ -139,17 +132,6 @@ function resourceServer(
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!input.emptyElicitation && !input.urlElicitation) {
|
||||
protocol.setRequestHandler(CallToolRequestSchema, (request) => {
|
||||
state.toolCalls.push({
|
||||
name: request.params.name,
|
||||
arguments: request.params.arguments,
|
||||
sessionID: request.params._meta?.sessionID,
|
||||
progressToken: request.params._meta?.progressToken,
|
||||
})
|
||||
return Promise.resolve({ content: [] })
|
||||
})
|
||||
}
|
||||
if (input.resources !== false) {
|
||||
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
||||
state.resourceLists += 1
|
||||
@@ -331,7 +313,6 @@ const mcp = Layer.mock(Mcp.Service, {
|
||||
callTool: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls += 1
|
||||
invocations.push(input)
|
||||
if (input.name === "fail")
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
@@ -399,43 +380,6 @@ test("MCP tool names match V1 sanitization", () => {
|
||||
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
||||
})
|
||||
|
||||
test("passes session IDs as MCP request metadata", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer()
|
||||
const connection = yield* connect(
|
||||
"session-metadata",
|
||||
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||
import.meta.dir,
|
||||
)
|
||||
yield* connection.callTool({
|
||||
name: "echo",
|
||||
args: { text: "hello" },
|
||||
sessionID: Session.ID.make("ses_mcp_metadata"),
|
||||
})
|
||||
yield* connection.callTool({ name: "echo" })
|
||||
|
||||
expect(server.state.toolCalls).toEqual([
|
||||
{
|
||||
name: "echo",
|
||||
arguments: { text: "hello" },
|
||||
sessionID: "ses_mcp_metadata",
|
||||
progressToken: expect.any(Number),
|
||||
},
|
||||
{
|
||||
name: "echo",
|
||||
arguments: {},
|
||||
sessionID: undefined,
|
||||
progressToken: expect.any(Number),
|
||||
},
|
||||
])
|
||||
expect(server.state.toolCalls[0]?.progressToken).not.toBe(server.state.toolCalls[1]?.progressToken)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves output schema validation across paginated tool discovery", async () => {
|
||||
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
|
||||
@@ -1666,54 +1610,6 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards the invoking session through direct and Code Mode MCP tools", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
invocations = []
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
|
||||
"properties.sessionID",
|
||||
)
|
||||
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
|
||||
|
||||
const directSessionID = Session.ID.make("ses_mcp_direct")
|
||||
yield* toolSet.execute({
|
||||
sessionID: directSessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_mcp_direct", name: "direct_lookup", input: {} },
|
||||
})
|
||||
expect(invocations[0]).toEqual({
|
||||
server: "direct",
|
||||
name: "lookup",
|
||||
args: {},
|
||||
sessionID: directSessionID,
|
||||
})
|
||||
|
||||
const codeModeSessionID = Session.ID.make("ses_mcp_codemode")
|
||||
yield* toolSet.execute({
|
||||
sessionID: codeModeSessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_mcp_codemode",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo.search({})" },
|
||||
},
|
||||
})
|
||||
expect(invocations[1]).toEqual({
|
||||
server: "demo",
|
||||
name: "search",
|
||||
args: {},
|
||||
sessionID: codeModeSessionID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
|
||||
@@ -55,6 +55,62 @@ async function createGitFixture(directory: string) {
|
||||
return { repository, commit }
|
||||
}
|
||||
|
||||
async function createRegistryFixture(directory: string) {
|
||||
const tarballs = new Map<string, Uint8Array>()
|
||||
for (const version of ["1.0.0", "1.1.0"]) {
|
||||
const root = path.join(directory, version)
|
||||
await fs.mkdir(path.join(root, "package"), { recursive: true })
|
||||
await writePackage(path.join(root, "package"), {
|
||||
name: "@fixture/registry-plugin",
|
||||
version,
|
||||
exports: "./index.js",
|
||||
})
|
||||
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
|
||||
await Bun.$`tar -czf package.tgz package`.cwd(root)
|
||||
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
|
||||
}
|
||||
const state = { latest: "1.0.0", offline: false, requests: 0, tarballRequests: 0 }
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
state.requests++
|
||||
if (state.offline) return new Response("offline", { status: 503 })
|
||||
const url = new URL(request.url)
|
||||
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
|
||||
return Response.json({
|
||||
name: "@fixture/registry-plugin",
|
||||
"dist-tags": { latest: state.latest },
|
||||
versions: Object.fromEntries(
|
||||
[...tarballs.keys()].map((version) => [
|
||||
version,
|
||||
{ name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } },
|
||||
]),
|
||||
),
|
||||
})
|
||||
state.tarballRequests++
|
||||
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
|
||||
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
|
||||
},
|
||||
})
|
||||
return {
|
||||
state,
|
||||
tarball: `${server.url}1.0.0.tgz`,
|
||||
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"),
|
||||
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
|
||||
)
|
||||
return root
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
await server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
|
||||
@@ -131,6 +187,7 @@ describe("Npm.add", () => {
|
||||
|
||||
expect(entry.directory).toBe(directory)
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(entry.revision).toBe("1.0.0")
|
||||
})
|
||||
|
||||
test("falls back to the original spec when parsing fails", async () => {
|
||||
@@ -198,6 +255,7 @@ describe("Npm.add", () => {
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
|
||||
expect(entries.added.entrypoint).toEndWith("/index.js")
|
||||
expect(entries.added.revision).toBe(fixture.commit)
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
@@ -218,6 +276,7 @@ describe("Npm.add", () => {
|
||||
|
||||
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(entry.revision).toBe(fixture.commit)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
@@ -234,13 +293,29 @@ describe("Npm.add", () => {
|
||||
|
||||
const first = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const mutableEntry = yield* npm.add(mutable)
|
||||
const mutableEntry = yield* npm.add(mutable, { refresh: true })
|
||||
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
|
||||
yield* Effect.promise(async () => {
|
||||
const nextCommit = yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
|
||||
return Bun.$`git -C ${fixture.repository} rev-parse HEAD`.text().then((value) => value.trim())
|
||||
})
|
||||
const lock = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(mutable)), "package-lock.json")
|
||||
const before = yield* Effect.promise(() => Bun.file(lock).bytes())
|
||||
expect((yield* npm.resolve(mutable)).revision).toBe(fixture.commit)
|
||||
expect(yield* npm.check(mutable)).toEqual({ installed: fixture.commit, available: nextCommit, mutable: true })
|
||||
expect(yield* npm.check(pinned)).toEqual({ installed: fixture.commit, available: fixture.commit, mutable: false })
|
||||
expect(yield* Effect.promise(() => Bun.file(lock).bytes())).toEqual(before)
|
||||
expect(yield* npm.add(mutable)).toEqual(mutableEntry)
|
||||
expect(mutableEntry.revision).toBe(fixture.commit)
|
||||
|
||||
const uncached = `git+${repository}#HEAD`
|
||||
expect(yield* npm.check(uncached)).toEqual({ installed: undefined, available: nextCommit, mutable: true })
|
||||
expect(yield* Effect.promise(() => fs.readdir(path.join(cache, "packages")))).not.toContain(
|
||||
yield* Effect.promise(() => Npm.cacheKey(uncached)),
|
||||
)
|
||||
expect((yield* npm.resolve(uncached)).entrypoint).toBeUndefined()
|
||||
yield* npm.add(mutable, { refresh: true })
|
||||
return { mutable: mutableEntry, pinned: pinnedEntry }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
@@ -260,6 +335,8 @@ describe("Npm.add", () => {
|
||||
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
|
||||
const offline = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
expect((yield* npm.resolve(mutable)).revision).toBe(second.mutable.revision)
|
||||
expect((yield* npm.check(mutable).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
|
||||
return yield* npm.add(mutable, { refresh: true })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
|
||||
@@ -291,5 +368,92 @@ describe("Npm.resolve", () => {
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(resolved.entrypoint).toEndWith("/tui.js")
|
||||
expect(resolved.revision).toBe("1.0.0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.check and revision metadata", () => {
|
||||
test("checks registry tags, ranges, and exact pins without changing or installing packages", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "@fixture/registry-plugin@latest"
|
||||
const range = "@fixture/registry-plugin@^1.0.0"
|
||||
const pinned = "@fixture/registry-plugin@1.0.0"
|
||||
const alias = "alias@npm:@fixture/registry-plugin@latest"
|
||||
const root = await registry.configure(cache, spec)
|
||||
const rangeRoot = await registry.configure(cache, range)
|
||||
const pinnedRoot = await registry.configure(cache, pinned)
|
||||
await registry.configure(cache, alias)
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
for (const unsupported of [registry.tarball, alias]) {
|
||||
const error = yield* npm.check(unsupported).pipe(Effect.flip)
|
||||
expect(error._tag).toBe("NpmInstallFailedError")
|
||||
expect(error.cause).toEqual(new Error("Package checks only support registry and Git package specs"))
|
||||
}
|
||||
expect(registry.state.requests).toBe(0)
|
||||
expect(registry.state.tarballRequests).toBe(0)
|
||||
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
|
||||
const loaded = yield* npm.add(spec)
|
||||
expect(loaded.revision).toBe("1.0.0")
|
||||
const files = [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"node_modules/@fixture/registry-plugin/package.json",
|
||||
"node_modules/@fixture/registry-plugin/index.js",
|
||||
]
|
||||
const before = yield* Effect.promise(() =>
|
||||
Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes())),
|
||||
)
|
||||
const requests = registry.state.tarballRequests
|
||||
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: true })
|
||||
registry.state.latest = "1.1.0"
|
||||
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
|
||||
expect(yield* npm.check(range)).toEqual({ installed: undefined, available: "1.1.0", mutable: true })
|
||||
expect(yield* npm.check(pinned)).toEqual({ installed: undefined, available: "1.0.0", mutable: false })
|
||||
expect(yield* npm.add(spec)).toEqual(loaded)
|
||||
expect(yield* npm.resolve(spec)).toEqual(loaded)
|
||||
expect(
|
||||
yield* Effect.promise(() => Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes()))),
|
||||
).toEqual(before)
|
||||
expect(registry.state.tarballRequests).toBe(requests)
|
||||
expect(yield* Effect.promise(() => fs.readdir(rangeRoot))).toEqual([".npmrc"])
|
||||
expect(yield* Effect.promise(() => fs.readdir(pinnedRoot))).toEqual([".npmrc"])
|
||||
|
||||
registry.state.offline = true
|
||||
expect((yield* npm.check(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
|
||||
expect((yield* npm.resolve(spec)).revision).toBe("1.0.0")
|
||||
expect(yield* npm.add(spec)).toEqual(loaded)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
}, 30_000)
|
||||
|
||||
test("leaves unknown installed revisions undefined and prefers the wrapper Git lock", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const commit = "0123456789abcdef0123456789abcdef01234567"
|
||||
const spec = `git+https://example.com/fixture.git#${commit}`
|
||||
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
|
||||
const directory = path.join(root, "node_modules", "fixture")
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(root, { dependencies: { fixture: spec } })
|
||||
await writePackage(directory, { name: "fixture", exports: "./index.js" })
|
||||
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
|
||||
expect((yield* npm.add(spec)).revision).toBeUndefined()
|
||||
const lock = (revision: string) =>
|
||||
JSON.stringify({
|
||||
packages: { "node_modules/fixture": { resolved: `git+https://example.com/fixture.git#${revision}` } },
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(root, "node_modules", ".package-lock.json"), lock(commit)))
|
||||
expect((yield* npm.resolve(spec)).revision).toBe(commit)
|
||||
const installed = "f".repeat(40)
|
||||
yield* Effect.promise(() => Bun.write(path.join(root, "package-lock.json"), lock(installed)))
|
||||
expect((yield* npm.resolve(spec)).revision).toBe(installed)
|
||||
yield* Effect.promise(() => fs.rm(directory, { recursive: true }))
|
||||
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -450,19 +450,23 @@ describe("Plugin", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
for (const revision of ["1.0.0", undefined]) {
|
||||
yield* plugins.activate([{ ...versioned(previous, revision ?? "unknown"), revision }])
|
||||
yield* plugins.activate([{ ...versioned(replacement, "2"), revision: "2.0.0" }])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
...(revision === undefined ? {} : { revision }),
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
if (revision === undefined) expect((yield* plugins.list())[0]).not.toHaveProperty("revision")
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { PackageStatus } from "@opencode-ai/schema/plugin"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { it, testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const pluginIt = testEffect(PluginTestLayer)
|
||||
|
||||
// Bun.build inside bun test panics on Windows; the Node import boundary is platform-independent.
|
||||
describe.skipIf(process.platform === "win32")("Node plugin revisions", () => {
|
||||
it.live("keeps the loaded revision across Node CommonJS namespace wrappers", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-node-revision-")))
|
||||
const entrypoint = path.join(tmp.path, "plugin.cjs")
|
||||
const harness = path.join(tmp.path, "harness.ts")
|
||||
yield* Effect.promise(() =>
|
||||
fs.symlink(path.join(import.meta.dir, "../../../util/node_modules"), path.join(tmp.path, "node_modules")),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
harness,
|
||||
`
|
||||
import assert from "node:assert/strict"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { PluginModule } from ${JSON.stringify(path.join(import.meta.dir, "../../src/plugin/module.ts"))}
|
||||
import { Npm } from ${JSON.stringify(path.join(import.meta.dir, "../../../util/src/npm.ts"))}
|
||||
const entrypoint = process.argv[2]
|
||||
const source = (tui) => 'module.exports = { id: "node-revision-plugin", tui: ' + tui + ', effect: () => {} }'
|
||||
const load = (revision) => Effect.runPromise(PluginModule.load({ type: "add", target: "inspection-plugin@latest", options: {} }).pipe(
|
||||
Effect.provideService(Npm.Service, {
|
||||
add: () => Effect.succeed({ directory: "", entrypoint, revision }),
|
||||
resolve: () => Effect.die("unused resolve"),
|
||||
check: () => Effect.die("unused check"),
|
||||
which: () => Effect.die("unused which"),
|
||||
}),
|
||||
))
|
||||
await writeFile(entrypoint, source(false))
|
||||
const first = await load("1.0.0")
|
||||
assert.deepEqual({ tui: first.tui, revision: first.revision }, { tui: false, revision: "1.0.0" })
|
||||
await writeFile(entrypoint, source(true))
|
||||
const second = await load("2.0.0")
|
||||
assert.deepEqual({ tui: second.tui, revision: second.revision }, { tui: false, revision: "1.0.0" })
|
||||
`,
|
||||
),
|
||||
)
|
||||
const result = yield* Effect.promise(() =>
|
||||
Bun.build({
|
||||
entrypoints: [harness],
|
||||
outdir: tmp.path,
|
||||
naming: "[name].mjs",
|
||||
target: "node",
|
||||
conditions: ["node"],
|
||||
plugins: [
|
||||
{
|
||||
name: "externalize-dependencies",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^[^.#/]/ }, (args) =>
|
||||
args.path.startsWith("@opencode-ai/") ? undefined : { path: args.path, external: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
const child = Bun.spawn(["node", path.join(tmp.path, "harness.mjs"), entrypoint], {
|
||||
cwd: tmp.path,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: tmp.path,
|
||||
XDG_CONFIG_HOME: path.join(tmp.path, "config"),
|
||||
XDG_DATA_HOME: path.join(tmp.path, "data"),
|
||||
XDG_CACHE_HOME: path.join(tmp.path, "cache"),
|
||||
XDG_STATE_HOME: path.join(tmp.path, "state"),
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => child.kill()))
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]),
|
||||
),
|
||||
).toEqual([0, expect.any(String), expect.any(String)])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
pluginIt.live("keeps loaded revision tied to cached module code rather than refreshed disk metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-revision-")))
|
||||
const entrypoint = path.join(tmp.path, "plugin.ts")
|
||||
const source = (tui: boolean) =>
|
||||
`import { Effect } from ${JSON.stringify(import.meta.resolve("effect"))}\nexport default { id: "revision-plugin", tui: ${tui}, effect: () => Effect.void }\n`
|
||||
yield* Effect.promise(() => Bun.write(entrypoint, source(false)))
|
||||
const npm = yield* Npm.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const operation = { type: "add" as const, target: "inspection-plugin@latest", options: {} }
|
||||
const load = (revision: string, options: Record<string, unknown>) =>
|
||||
PluginModule.load({ ...operation, options }).pipe(
|
||||
Effect.provideService(Npm.Service, {
|
||||
...npm,
|
||||
add: () => Effect.succeed({ directory: tmp.path, entrypoint, revision }),
|
||||
}),
|
||||
)
|
||||
const first = yield* load("1.0.0", {})
|
||||
yield* plugins.activate([first])
|
||||
expect(first.version).toBe(JSON.stringify(operation))
|
||||
expect(first.source).toEqual({ type: "package", package: operation.target })
|
||||
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(entrypoint, source(true)))
|
||||
const second = yield* load("2.0.0", { changed: true })
|
||||
expect(second.tui).toBe(false)
|
||||
expect(second.revision).toBe("1.0.0")
|
||||
yield* plugins.activate([second])
|
||||
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
|
||||
yield* plugins.activate([{ ...second, version: "failed", effect: () => Effect.die("setup failed") }])
|
||||
expect((yield* plugins.list())[0]).toMatchObject({ status: "failed", revision: "1.0.0" })
|
||||
expect(Plugin.PackageStatus).toBe(PackageStatus)
|
||||
}),
|
||||
)
|
||||
|
||||
pluginIt.effect("rejects in-process checks in Effect and Promise setup without waiting for activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "effect-check",
|
||||
version: "1",
|
||||
effect: (host) =>
|
||||
host.plugin.check({ target: "inspection-plugin@latest" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) =>
|
||||
Effect.sync(() =>
|
||||
expect(error).toMatchObject({
|
||||
_tag: "PluginCheckError",
|
||||
message: expect.stringContaining("external client"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
Effect.asVoid,
|
||||
),
|
||||
},
|
||||
{
|
||||
...PluginPromise.fromPromise({
|
||||
id: "promise-check",
|
||||
setup: async (host) => {
|
||||
await expect(host.plugin.check({ target: "inspection-plugin@latest" })).rejects.toThrow("external client")
|
||||
},
|
||||
}),
|
||||
version: "1",
|
||||
},
|
||||
])
|
||||
expect((yield* plugins.list()).map((plugin) => plugin.status)).toEqual(["active", "active"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("checks configured package sources without installing, writing config, or updating plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-check-")))
|
||||
const target = "inspection-plugin@latest"
|
||||
const unsupported = "https://example.com/plugin.tgz"
|
||||
const config = JSON.stringify({ plugins: ["-*", target, unsupported] })
|
||||
const configPath = path.join(tmp.path, "opencode.json")
|
||||
yield* Effect.promise(() => Bun.write(configPath, config))
|
||||
let adds = 0
|
||||
let checks = 0
|
||||
let fail = false
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Bus.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[
|
||||
Npm.node,
|
||||
Layer.succeed(Npm.Service, {
|
||||
add: () => {
|
||||
adds++
|
||||
return Effect.fail(new Npm.InstallFailedError({ dir: tmp.path }))
|
||||
},
|
||||
resolve: () => Effect.succeed({ directory: tmp.path }),
|
||||
which: () => Effect.undefined,
|
||||
check: () => {
|
||||
checks++
|
||||
if (fail)
|
||||
return Effect.fail(
|
||||
new Npm.InstallFailedError({ dir: tmp.path, cause: new Error("registry unavailable") }),
|
||||
)
|
||||
return Effect.succeed({ available: "2.0.0", mutable: true })
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
)
|
||||
const locations = yield* LocationServiceMap.Service.pipe(Effect.provide(context))
|
||||
const bus = yield* Bus.Service.pipe(Effect.provide(context))
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* supervisor.flush
|
||||
const inventory = yield* plugins.list()
|
||||
const initialAdds = adds
|
||||
let updates = 0
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === Plugin.Event.Updated.type) updates++
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
expect(yield* supervisor.check(target)).toEqual({ available: "2.0.0", mutable: true })
|
||||
expect(yield* supervisor.check("not-configured@latest").pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "PluginCheckError",
|
||||
message: expect.stringContaining("server inventory"),
|
||||
})
|
||||
expect(yield* supervisor.check(unsupported).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "PluginCheckError",
|
||||
message: `Unsupported plugin package source: ${unsupported}`,
|
||||
})
|
||||
expect(checks).toBe(1)
|
||||
fail = true
|
||||
expect(yield* supervisor.check(target).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "PluginCheckError",
|
||||
message: expect.stringContaining("registry unavailable"),
|
||||
})
|
||||
expect(adds).toBe(initialAdds)
|
||||
expect(yield* plugins.list()).toEqual(inventory)
|
||||
expect(updates).toBe(0)
|
||||
expect(yield* Effect.promise(() => Bun.file(configPath).text())).toBe(config)
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
|
||||
}),
|
||||
)
|
||||
@@ -16,7 +16,6 @@ import { emptyMcpLayer } from "../fixture/mcp"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
import PROMPT_INITIALIZE from "../../src/plugin/command/initialize.txt"
|
||||
import PROMPT_REVIEW from "../../src/plugin/command/review.txt"
|
||||
|
||||
const directory = AbsolutePath.make("/repo/packages/app")
|
||||
@@ -41,11 +40,7 @@ describe("CommandPlugin.Plugin", () => {
|
||||
it.effect("registers built-in init and review commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const prompts: {
|
||||
text: string
|
||||
files?: readonly { readonly uri: string }[]
|
||||
delivery?: "steer" | "queue"
|
||||
}[] = []
|
||||
const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = []
|
||||
yield* CommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
@@ -56,7 +51,7 @@ describe("CommandPlugin.Plugin", () => {
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||
prompts.push({ text: input.text, files: input.files })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
@@ -91,50 +86,10 @@ describe("CommandPlugin.Plugin", () => {
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
yield* command.execute({
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: " branch $& $$ $` $' " },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* command.execute({
|
||||
name: "init",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* command.execute({
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: " " },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(prompts).toEqual([
|
||||
{
|
||||
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
|
||||
text: expect.stringContaining("extra context"),
|
||||
files: [{ uri: "file:///tmp/context.md" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
{
|
||||
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{
|
||||
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{
|
||||
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -38,6 +38,7 @@ const npmLayer = Layer.succeed(
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.undefined,
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
},
|
||||
plugin: overrides.plugin ?? {
|
||||
list: () => Effect.die("unused plugin.list"),
|
||||
check: () => Effect.die("unused plugin.check"),
|
||||
},
|
||||
reference: overrides.reference ?? {
|
||||
list: () => Effect.die("unused reference.list"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -11,8 +11,6 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -20,7 +18,6 @@ import { withEnv } from "../fixture/env"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { catalogHost, host, integrationHost } from "./host"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -30,68 +27,10 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
|
||||
[Location.node, locationLayer],
|
||||
])
|
||||
const it = testEffect(layer)
|
||||
const real = testEffect(PluginTestLayer)
|
||||
const models = (file: string) =>
|
||||
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const modelID = Model.ID.make("model")
|
||||
const modelsDev = ModelsDev.Service.of({
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
{
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
models: [
|
||||
{
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
name: "Model",
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 32_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]),
|
||||
refresh: () => Effect.void,
|
||||
})
|
||||
const pluginHost = yield* PluginHost.make(plugins)
|
||||
yield* ModelsDevPlugin.effect(pluginHost).pipe(Effect.provideService(ModelsDev.Service, modelsDev))
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* catalog
|
||||
.transform((draft) =>
|
||||
draft.model.update(providerID, modelID, (model) => {
|
||||
model.variants ??= []
|
||||
model.variants.push({ id: Model.VariantID.make("configured") })
|
||||
}),
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([
|
||||
{ id: Model.VariantID.make("configured") },
|
||||
])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects normalized models.dev snapshots into the catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { expect, test } from "bun:test"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Effect } from "effect"
|
||||
|
||||
test("loads cached plugin packages without requesting a refresh", async () => {
|
||||
const calls: unknown[] = []
|
||||
const entrypoint = path.join(import.meta.dir, "fixtures", "config-effect-plugin.ts")
|
||||
const plugin = await PluginModule.load({ type: "add", target: "fixture-plugin", options: {} }).pipe(
|
||||
Effect.provideService(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: (_pkg, options) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(options)
|
||||
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
|
||||
}),
|
||||
resolve: () => Effect.die(new Error("Unexpected resolve")),
|
||||
which: () => Effect.die(new Error("Unexpected which")),
|
||||
}),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
expect(plugin.id).toBe("config-effect-plugin")
|
||||
expect(calls).toEqual([{ subpaths: ["server", ""] }])
|
||||
})
|
||||
@@ -25,6 +25,7 @@ function npmEntrypoint(entrypoint?: string) {
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
which: () => Effect.undefined,
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.undefined,
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
|
||||
@@ -70,12 +70,20 @@ describe("AppProcess", () => {
|
||||
"requireSuccess fails on non-zero exit",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const error = yield* svc
|
||||
.run(cmd("-e", "process.exit(1)"))
|
||||
.pipe(Effect.flatMap(AppProcess.requireSuccess), Effect.flip)
|
||||
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect(error.exitCode).toBe(1)
|
||||
expect(error.message).toContain("Command failed (exit 1)")
|
||||
const exit = yield* Effect.exit(
|
||||
svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
|
||||
expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)")
|
||||
} else {
|
||||
throw new Error("expected fail reason")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -97,9 +105,15 @@ describe("AppProcess", () => {
|
||||
expect(okZero.exitCode).toBe(0)
|
||||
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
|
||||
expect(okOne.exitCode).toBe(1)
|
||||
const error = yield* svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne), Effect.flip)
|
||||
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect(error.exitCode).toBe(2)
|
||||
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -299,38 +313,18 @@ describe("AppProcess", () => {
|
||||
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(allowed)).toEqual(["only"])
|
||||
const error = yield* svc
|
||||
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect, Effect.flip)
|
||||
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"includes stderr in output while retaining capped failure diagnostics",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const lines: string[] = []
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runStream(
|
||||
cmd(
|
||||
"-e",
|
||||
"console.log('stdout-line'); console.error('stderr-line'); console.error('diagnostic-tail'); process.exit(2)",
|
||||
),
|
||||
{ includeStderr: true, maxErrorBytes: 20, okExitCodes: [0] },
|
||||
)
|
||||
.pipe(Stream.runForEach((line) => Effect.sync(() => lines.push(line)))),
|
||||
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
|
||||
expect(lines.toSorted()).toEqual(["diagnostic-tail", "stderr-line", "stdout-line"])
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (!Exit.isFailure(exit)) return
|
||||
const reason = exit.cause.reasons[0]
|
||||
expect(reason?._tag).toBe("Fail")
|
||||
if (!reason || reason._tag !== "Fail") return
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect(reason.error.stderr).toBe("stderr-line\ndiagnost")
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ describe("RepositoryCache", () => {
|
||||
await fs.writeFile(path.join(localPath, "stale.txt"), "stale")
|
||||
})
|
||||
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const result = yield* cache.ensure({ reference: fixture.reference })
|
||||
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
|
||||
|
||||
expect(result.status).toBe("cloned")
|
||||
expect(yield* exists(path.join(localPath, "stale.txt"))).toBe(false)
|
||||
@@ -95,8 +94,7 @@ describe("RepositoryCache", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
|
||||
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const result = yield* cache.ensure({ reference: fixture.reference })
|
||||
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
|
||||
|
||||
expect(result.status).toBe("cloned")
|
||||
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
|
||||
|
||||
@@ -20,8 +20,6 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -303,54 +301,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("manual compaction records model resolution failures without calling the model", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = Session.ID.make("ses_manual_resolution_failure")
|
||||
const session = yield* insertSession(sessionID)
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const inputID = SessionMessage.ID.make("msg_manual_resolution_failure")
|
||||
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () =>
|
||||
Effect.fail(
|
||||
new SessionRunnerModel.ModelUnavailableError({
|
||||
providerID: Provider.ID.make("test"),
|
||||
modelID: Model.ID.make("missing"),
|
||||
}),
|
||||
),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID,
|
||||
}),
|
||||
).toEqual({
|
||||
status: "failed",
|
||||
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
|
||||
})
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{
|
||||
id: inputID,
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -67,17 +67,6 @@ const liveIt = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
const projectIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
|
||||
[LocationServiceMap.node, promptLocationLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const id = Session.ID.create()
|
||||
|
||||
@@ -130,7 +119,7 @@ describe("Session.create", () => {
|
||||
),
|
||||
)
|
||||
|
||||
projectIt.live("follows the directory's project identity established after creation", () =>
|
||||
liveIt.live("follows the directory's project identity established after creation", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -857,7 +857,8 @@ describe("SessionModelTransport", () => {
|
||||
test("records metadata-only lifecycle metrics", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await Effect.runPromise(
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
@@ -873,11 +874,7 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
expect(JSON.stringify(lifecycle)).not.toContain("secret-one")
|
||||
expect(JSON.stringify(lifecycle)).not.toContain("secret-two")
|
||||
}).pipe(
|
||||
Effect.provide(SessionModelTransport.makeLayer(fixture.connector)),
|
||||
Effect.scoped,
|
||||
Effect.provideService(Metric.MetricRegistry, new Map()),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,11 +78,15 @@ const locations = Layer.effect(
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
|
||||
restore: () =>
|
||||
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
PluginSupervisor.Service.of({
|
||||
flush: Effect.sync(() => (ready = true)),
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
@@ -171,9 +175,8 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||
describe("Session.prompt", () => {
|
||||
it.effect("exposes the execution registry", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
activeSessions.add(sessionID)
|
||||
expect(Array.from(yield* session.active)).toEqual([sessionID])
|
||||
expect(Array.from(yield* (yield* Session.Service).active)).toEqual([sessionID])
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
|
||||
)
|
||||
|
||||
@@ -557,7 +560,7 @@ describe("Session.prompt", () => {
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([message.id])
|
||||
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
|
||||
expect(executionCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
|
||||
@@ -85,7 +85,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
})
|
||||
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Config.testLayer()
|
||||
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
|
||||
)
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.undefined,
|
||||
|
||||
@@ -390,6 +390,7 @@ const layer = Layer.unwrap(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
flush: Effect.suspend(() => state.pluginFlushHook),
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
}),
|
||||
)
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
|
||||
@@ -38,7 +38,7 @@ const skills = Layer.mergeAll(
|
||||
get: (id) => Effect.succeed(id === info.id ? info : undefined),
|
||||
list: () => Effect.succeed([info]),
|
||||
}),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
|
||||
@@ -105,9 +105,11 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
|
||||
execute: () => new ForeignFailure({ message: "transport died" }) as never,
|
||||
}
|
||||
|
||||
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
|
||||
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
|
||||
expect(exit._tag).toBe("Failure")
|
||||
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
|
||||
expect(error).toBeInstanceOf(Tool.Error)
|
||||
expect(error.message).toBe("transport died")
|
||||
expect((error as Tool.Error).message).toBe("transport died")
|
||||
})
|
||||
|
||||
test("execute supports callable namespace tools", async () => {
|
||||
|
||||
@@ -127,7 +127,9 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(
|
||||
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
|
||||
),
|
||||
),
|
||||
deps: [
|
||||
Config.node,
|
||||
|
||||
@@ -100,7 +100,9 @@ const subagentPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(
|
||||
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
|
||||
),
|
||||
),
|
||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
})
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("Worktree", () => {
|
||||
{ directory: created.directory, strategy: "git" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
|
||||
@@ -550,7 +550,7 @@ describe("Worktree", () => {
|
||||
{ directory: existing, strategy: "git" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { WebSearchApi } from "@opencode-ai/client/effect/api"
|
||||
import type { WebsearchApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface WebSearchDefinition {
|
||||
readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
|
||||
}
|
||||
|
||||
export interface WebSearchDomain extends WebSearchApi<unknown> {
|
||||
export interface WebSearchDomain extends WebsearchApi<unknown> {
|
||||
readonly transform: Transform<WebSearchDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
},
|
||||
plugin: {
|
||||
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
|
||||
check: adaptApiMethod(PluginEndpoints["plugin.check"], host.plugin.check),
|
||||
},
|
||||
reference: {
|
||||
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
|
||||
|
||||
@@ -486,6 +486,123 @@
|
||||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/check": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.check",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.InfoEncoded"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Plugin.PackageStatus"
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "PluginCheckError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
|
||||
"summary": "Check plugin package",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["target"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/session": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -16307,6 +16424,9 @@
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Plugin.Source"
|
||||
},
|
||||
"revision": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active"]
|
||||
@@ -16327,6 +16447,9 @@
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Plugin.Source"
|
||||
},
|
||||
"revision": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["failed"]
|
||||
@@ -16343,6 +16466,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Plugin.PackageStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"installed": {
|
||||
"type": "string"
|
||||
},
|
||||
"available": {
|
||||
"type": "string"
|
||||
},
|
||||
"mutable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["mutable"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Plugin.Source": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -16397,6 +16536,20 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"PluginCheckErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["PluginCheckError"]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -55,13 +55,15 @@ export const makeFormGroup = <
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
|
||||
error: SessionNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.list",
|
||||
summary: "List session forms",
|
||||
description: "Retrieve pending forms for a session.",
|
||||
}),
|
||||
),
|
||||
})
|
||||
.middleware(formLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.list",
|
||||
summary: "List session forms",
|
||||
description: "Retrieve pending forms for a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
|
||||
|
||||
@@ -90,13 +90,15 @@ export const makePermissionGroup = <
|
||||
params: { sessionID: Session.ID },
|
||||
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
|
||||
error: SessionNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.permission.list",
|
||||
summary: "List session permission requests",
|
||||
description: "Retrieve pending permission requests owned by a session.",
|
||||
}),
|
||||
),
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.permission.list",
|
||||
summary: "List session permission requests",
|
||||
description: "Retrieve pending permission requests owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", {
|
||||
|
||||
@@ -19,6 +19,23 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({ target: Schema.String }),
|
||||
success: Location.response(Plugin.PackageStatus),
|
||||
error: Plugin.CheckError.annotate({ httpApiStatus: 400 }),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.check",
|
||||
summary: "Check plugin package",
|
||||
description:
|
||||
"Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "plugin",
|
||||
|
||||
@@ -19,12 +19,14 @@ export const Info = Schema.Union([
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
source: Source,
|
||||
revision: optional(Schema.String),
|
||||
status: Schema.Literal("active"),
|
||||
tui: Schema.Boolean,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ID.pipe(optional),
|
||||
source: Source,
|
||||
revision: optional(Schema.String),
|
||||
status: Schema.Literal("failed"),
|
||||
error: Schema.String,
|
||||
tui: Schema.Boolean,
|
||||
@@ -32,6 +34,19 @@ export const Info = Schema.Union([
|
||||
]).annotate({ identifier: "Plugin.Info" })
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const PackageStatus = Schema.Struct({
|
||||
installed: optional(Schema.String),
|
||||
available: optional(Schema.String),
|
||||
mutable: Schema.Boolean,
|
||||
}).annotate({ identifier: "Plugin.PackageStatus" })
|
||||
export interface PackageStatus extends Schema.Schema.Type<typeof PackageStatus> {}
|
||||
|
||||
export class CheckError extends Schema.TaggedError<CheckError>()(
|
||||
"PluginCheckError",
|
||||
{ message: Schema.String },
|
||||
{ identifier: "PluginCheckError" },
|
||||
) {}
|
||||
|
||||
const Added = ephemeral({
|
||||
type: "plugin.added",
|
||||
schema: { id: ID },
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Plugin } from "../src/plugin.js"
|
||||
|
||||
test("plugin inspection omits absent revisions", () => {
|
||||
expect(
|
||||
Schema.encodeSync(Plugin.PackageStatus)({ installed: undefined, available: undefined, mutable: true }),
|
||||
).toEqual({ mutable: true })
|
||||
for (const status of ["active", "failed"] as const) {
|
||||
const info = {
|
||||
id: Plugin.ID.make("example"),
|
||||
source: { type: "package" as const, package: "example@latest" },
|
||||
status,
|
||||
error: "setup failed",
|
||||
tui: false,
|
||||
revision: undefined,
|
||||
}
|
||||
expect(Schema.encodeSync(Plugin.Info)(info)).not.toHaveProperty("revision")
|
||||
expect(Schema.encodeSync(Plugin.Info)({ ...info, revision: "1.2.3" })).toHaveProperty("revision", "1.2.3")
|
||||
}
|
||||
})
|
||||
|
||||
test("plugin inspection contracts have stable identifiers and readable errors", () => {
|
||||
expect(Plugin.PackageStatus.ast.annotations?.identifier).toBe("Plugin.PackageStatus")
|
||||
expect(Plugin.CheckError.ast.annotations?.identifier).toBe("PluginCheckError")
|
||||
const error = new Plugin.CheckError({ message: "Plugin package is not configured: example" })
|
||||
expect(error.message).toBe("Plugin package is not configured: example")
|
||||
expect(Schema.encodeSync(Plugin.CheckError)(error)).toEqual({
|
||||
_tag: "PluginCheckError",
|
||||
message: error.message,
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,7 @@ for (const selection of ["explicit", "default"] as const) {
|
||||
PluginSupervisor.Service,
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
|
||||
return { flush: release.open.pipe(Effect.andThen(plugins.flush)), check: plugins.check }
|
||||
}),
|
||||
).pipe(Layer.provide(PluginSupervisor.layer))
|
||||
const opencode = yield* fixture.sdk.OpenCode.create(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import {
|
||||
ConflictError,
|
||||
FormAlreadySettledError,
|
||||
@@ -8,10 +6,10 @@ import {
|
||||
FormNotFoundError,
|
||||
InvalidRequestError,
|
||||
} from "@opencode-ai/protocol/errors"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location"
|
||||
import { response } from "../location"
|
||||
|
||||
function missingForm(id: Form.ID) {
|
||||
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
|
||||
@@ -19,8 +17,6 @@ function missingForm(id: Form.ID) {
|
||||
|
||||
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const database = yield* Database.Service
|
||||
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
|
||||
const form = yield* Form.Service
|
||||
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
|
||||
@@ -39,16 +35,9 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
|
||||
.handle(
|
||||
"session.form.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
const ref =
|
||||
ctx.params.sessionID === "global"
|
||||
? requestRef(ctx.request)
|
||||
: yield* sessionRef(database, ctx.params.sessionID)
|
||||
const forms = yield* withLoadedLocationServices(
|
||||
locations,
|
||||
ref,
|
||||
Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })),
|
||||
)
|
||||
return { data: Option.getOrElse(forms, () => []) }
|
||||
const form = yield* Form.Service
|
||||
const forms = yield* form.list({ sessionID: ctx.params.sessionID })
|
||||
return { data: forms }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { response, sessionRef, withLoadedLocationServices } from "../location"
|
||||
import { response } from "../location"
|
||||
|
||||
function missingRequest(id: Permission.ID) {
|
||||
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
|
||||
@@ -15,8 +13,6 @@ function missingRequest(id: Permission.ID) {
|
||||
|
||||
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const database = yield* Database.Service
|
||||
const requireOwnedRequest = Effect.fnUntraced(function* (
|
||||
sessionID: Permission.Request["sessionID"],
|
||||
requestID: Permission.ID,
|
||||
@@ -67,13 +63,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
|
||||
.handle(
|
||||
"session.permission.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
const ref = yield* sessionRef(database, ctx.params.sessionID)
|
||||
const requests = yield* withLoadedLocationServices(
|
||||
locations,
|
||||
ref,
|
||||
Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID)),
|
||||
)
|
||||
return { data: Option.getOrElse(requests, () => []) }
|
||||
const permission = yield* Permission.Service
|
||||
return { data: yield* permission.forSession(ctx.params.sessionID) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handlers) =>
|
||||
handlers.handle("plugin.list", () =>
|
||||
Effect.gen(function* () {
|
||||
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
|
||||
}),
|
||||
),
|
||||
handlers
|
||||
.handle("plugin.list", () =>
|
||||
Effect.gen(function* () {
|
||||
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
|
||||
}),
|
||||
)
|
||||
.handle("plugin.check", ({ payload }) =>
|
||||
response(PluginSupervisor.Service.use((supervisor) => supervisor.check(payload.target))),
|
||||
),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user