mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 13:36:18 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91802603ea | ||
|
|
8089091e82 | ||
|
|
0a718be0d9 | ||
|
|
67845091ba | ||
|
|
fe788b7842 | ||
|
|
ee42eb3ca3 | ||
|
|
80323a4deb | ||
|
|
a35f96f427 | ||
|
|
d354c3d640 | ||
|
|
ce005ce002 | ||
|
|
964245bc2a | ||
|
|
aea3e7c1d2 | ||
|
|
3625942952 | ||
|
|
0593a6b8eb | ||
|
|
426e5c6389 | ||
|
|
ebdfcf4866 | ||
|
|
000d0882c3 | ||
|
|
6062e30cb9 | ||
|
|
3badee1a3c | ||
|
|
4a0256d374 | ||
|
|
52ec62bef0 | ||
|
|
31af9858fd | ||
|
|
3151660fbb | ||
|
|
0362ef48ff | ||
|
|
facd7ff452 | ||
|
|
134cdda333 | ||
|
|
5634ef1bb6 | ||
|
|
2379ab3d51 | ||
|
|
5c908ebba5 | ||
|
|
ba0755d933 | ||
|
|
f7d6b00c1e |
@@ -1073,9 +1073,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@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",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2212,27 +2212,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
"@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-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
"@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-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
"@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-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
"@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-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
"@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-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
"@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-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
"@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-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
"@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/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/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/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=="],
|
||||
"@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=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -6564,6 +6564,10 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7450,6 +7454,30 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -8248,6 +8276,8 @@
|
||||
|
||||
"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": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@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",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -1367,7 +1367,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1496,7 +1496,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
Vendored
+2
@@ -1,5 +1,7 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
|
||||
import "@solidjs/start"
|
||||
|
||||
export declare module "@solidjs/start/server" {
|
||||
export type APIEvent = { request: Request }
|
||||
}
|
||||
|
||||
@@ -482,8 +482,7 @@ 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
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
|
||||
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
|
||||
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
|
||||
return false
|
||||
})
|
||||
return toolResultPart({
|
||||
@@ -507,7 +506,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: part.data, filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -516,7 +515,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: part.data, filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
|
||||
case "reasoning":
|
||||
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "tool-call":
|
||||
@@ -535,6 +534,15 @@ 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,16 +859,12 @@ export function configured(options?: Options) {
|
||||
aggregateID: input.aggregateID,
|
||||
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
|
||||
}
|
||||
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
|
||||
Stream.map((event): LogItem => event),
|
||||
Stream.concat(Stream.make(marker)),
|
||||
)
|
||||
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(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,14 +20,13 @@ 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
|
||||
entries.set(
|
||||
draft.add(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
@@ -48,7 +47,6 @@ export const Plugin = define({
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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 * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
|
||||
* import * as Effect from 'effect/Effect';
|
||||
* import { SqliteClient } from "@effect/sql-sqlite-node"
|
||||
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
* import { Effect } from "effect"
|
||||
*
|
||||
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
|
||||
* Effect.provide(SQLiteDrizzle.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
|
||||
* );
|
||||
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
|
||||
* Effect.provide(EffectDrizzleSqlite.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(
|
||||
this.config.table._.columns,
|
||||
getTableColumnsRuntime(this.config.table),
|
||||
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,76 +521,75 @@ 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 migrate = Effect.gen(function* () {
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
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()
|
||||
}),
|
||||
)
|
||||
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`
|
||||
.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`,
|
||||
)
|
||||
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,
|
||||
@@ -605,81 +604,79 @@ 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)}`,
|
||||
})
|
||||
.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 },
|
||||
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()
|
||||
}),
|
||||
)
|
||||
.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* () {
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
|
||||
.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: KVTable.key,
|
||||
set: { value: { phase: "completed" }, time_updated: Date.now() },
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
return yield* migrate
|
||||
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 }
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
@@ -708,7 +705,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,21 +61,23 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
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() })
|
||||
},
|
||||
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() }
|
||||
}),
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
@@ -89,17 +91,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
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)
|
||||
},
|
||||
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))
|
||||
}),
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
@@ -107,32 +109,33 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
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),
|
||||
})
|
||||
},
|
||||
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),
|
||||
})
|
||||
}),
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
This is a temporary package used primarily for GitHub Copilot compatibility.
|
||||
# GitHub Copilot AI SDK Adapters
|
||||
|
||||
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
|
||||
This directory contains upstream-derived AI SDK implementations adapted for
|
||||
GitHub Copilot. It is not a generic OpenAI-compatible provider.
|
||||
|
||||
Avoid making edits to these files
|
||||
## 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`
|
||||
|
||||
@@ -653,7 +653,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
if (isActiveText) {
|
||||
controller.enqueue({ type: "text-end", id: "txt-0" })
|
||||
controller.enqueue({
|
||||
type: "text-end",
|
||||
id: "txt-0",
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 } from "../image.js"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
|
||||
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
|
||||
@@ -33,12 +33,7 @@ export const make = Effect.gen(function* () {
|
||||
return Effect.fn("Image.Photon.normalize")(function* (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
limits: {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
},
|
||||
limits: Readonly<Limits>,
|
||||
) {
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
|
||||
@@ -37,6 +37,7 @@ 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,6 +26,7 @@ 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
|
||||
@@ -156,6 +157,7 @@ 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. */
|
||||
@@ -396,7 +398,11 @@ export const connect = Effect.fnUntraced(function* (
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.callTool(
|
||||
{ name: input.name, arguments: input.args ?? {} },
|
||||
{
|
||||
name: input.name,
|
||||
arguments: input.args ?? {},
|
||||
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
|
||||
},
|
||||
CallToolResultSchema,
|
||||
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
|
||||
{ signal, timeout: executionTimeout, onprogress: () => {} },
|
||||
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -153,6 +154,7 @@ 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[]>
|
||||
@@ -762,7 +764,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 })
|
||||
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
|
||||
.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, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, PersistentPty, 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,19 +26,9 @@ 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 = 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 Info = PersistentPty.Info
|
||||
|
||||
export type Snapshot = {
|
||||
readonly info: Info
|
||||
readonly text: string
|
||||
readonly checkpoint: Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
export type Snapshot = PersistentPty.Snapshot
|
||||
|
||||
export type Attachment = {
|
||||
readonly info: Info
|
||||
@@ -161,15 +151,7 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
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
|
||||
},
|
||||
input: Parameters<Interface["create"]>[1],
|
||||
) {
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
@@ -338,14 +320,7 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
input: Parameters<Interface["attach"]>[1],
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
|
||||
@@ -84,7 +84,9 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
function append(template: string, input: string) {
|
||||
return [template, input.trim()].filter(Boolean).join("\n\n")
|
||||
const value = input.trim()
|
||||
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
|
||||
return [template, value].filter(Boolean).join("\n\n")
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
|
||||
@@ -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, model))
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).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
|
||||
|
||||
@@ -76,11 +76,13 @@ 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.
|
||||
|
||||
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,
|
||||
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,
|
||||
then does the same for `.opencode/opencode.json(c)` files. This means every
|
||||
`.opencode` config overrides every direct config. Global configuration has the
|
||||
lowest precedence.
|
||||
discovered `.opencode` config overrides every discovered direct config. Global
|
||||
filesystem configuration has lower precedence than these discovered documents.
|
||||
|
||||
Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||
|
||||
@@ -78,6 +78,9 @@ 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] : [],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,6 +92,7 @@ export const layer = Layer.effect(
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const npm = yield* Npm.Service
|
||||
const ready = yield* Latch.make()
|
||||
let observed = 0
|
||||
|
||||
@@ -113,6 +117,18 @@ 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.
|
||||
|
||||
@@ -134,10 +134,9 @@ const layer = Layer.effect(
|
||||
}),
|
||||
Stream.take(input.limit + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
const truncated = rows.length > input.limit
|
||||
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
|
||||
if (truncated) return rows.slice(0, input.limit)
|
||||
|
||||
const code = yield* handle.exitCode
|
||||
const stderr = yield* Fiber.join(stderrFiber)
|
||||
@@ -147,7 +146,7 @@ const layer = Layer.effect(
|
||||
if (code !== 0 && code !== 1 && code !== 2) {
|
||||
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
|
||||
}
|
||||
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
|
||||
return code === 1 ? [] : rows
|
||||
}),
|
||||
)
|
||||
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
|
||||
@@ -178,7 +177,7 @@ const layer = Layer.effect(
|
||||
parse: (line) => Effect.succeed(normalizePath(line)),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
result.items.map((relative) =>
|
||||
result.map((relative) =>
|
||||
Entry.make({
|
||||
path: RelativePath.make(relative),
|
||||
type: "file",
|
||||
@@ -212,10 +211,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
},
|
||||
onItem: input.onEntry,
|
||||
}).pipe(
|
||||
Effect.map((result) => result.items),
|
||||
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
|
||||
),
|
||||
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
|
||||
grep: (input) =>
|
||||
run<RawMatchData>({
|
||||
...input,
|
||||
@@ -248,7 +244,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
result.items.map((match) =>
|
||||
result.map((match) =>
|
||||
Match.make({
|
||||
entry: Entry.make({
|
||||
path: RelativePath.make(match.path.text),
|
||||
|
||||
@@ -393,26 +393,27 @@ export const layer = Layer.effect(
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
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,11 +131,7 @@ 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: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
readonly value: Schema.Json
|
||||
}) {
|
||||
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
|
||||
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
|
||||
if (actualBytes > MaxValueBytes)
|
||||
yield* new ValueTooLargeError({
|
||||
@@ -159,10 +155,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
}) {
|
||||
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
|
||||
yield* db
|
||||
.update(InstructionEntryTable)
|
||||
.set({ value: null, removed: true, time_updated: Date.now() })
|
||||
|
||||
@@ -43,10 +43,7 @@ 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: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
|
||||
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 Continuation } from "./index.js"
|
||||
import { DrainResult, Service, type Interface } 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,12 +44,7 @@ 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: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
|
||||
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({ created: EventTable.created, data: EventTable.data })
|
||||
.select({ 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 sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
const commands = new Map<Shell.ID, Active>()
|
||||
const exitOrder: Shell.ID[] = []
|
||||
|
||||
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 session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
for (const command of commands.values()) {
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
// Teardown interrupts pending commands; it is not a terminal command failure.
|
||||
yield* Deferred.interrupt(session.done)
|
||||
yield* Deferred.interrupt(command.done)
|
||||
}
|
||||
sessions.clear()
|
||||
commands.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
const require = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
const command = commands.get(id)
|
||||
if (!command) return yield* new NotFoundError({ id })
|
||||
return command
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const command = commands.get(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
if (!command) return
|
||||
commands.delete(id)
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
// Unblock any wait still pending when the command is removed before it terminated.
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||
yield* Deferred.fail(command.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
|
||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||
yield* require(id)
|
||||
yield* removeSession(id)
|
||||
yield* removeCommand(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Shell.list")(function* () {
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.info.status === "running")
|
||||
.map((session) => session.info)
|
||||
return Array.from(commands.values())
|
||||
.filter((command) => command.info.status === "running")
|
||||
.map((command) => command.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 session = yield* require(id)
|
||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||
yield* session.timeout(duration)
|
||||
return session.info
|
||||
const command = yield* require(id)
|
||||
if (command.info.status !== "running" || !command.timeout) return command.info
|
||||
yield* command.timeout(duration)
|
||||
return command.info
|
||||
})
|
||||
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const command = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const length = Math.min(limit, command.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
||||
const stream = createReadStream(command.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: session.size,
|
||||
size: command.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 session.
|
||||
// end). `create` returns once `ready` resolves with the registered command.
|
||||
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 session: Active = {
|
||||
const command: Active = {
|
||||
info: produce(info, (draft) => {
|
||||
draft.pid = handle.pid
|
||||
}),
|
||||
@@ -283,7 +283,7 @@ const layer = () =>
|
||||
size: 0,
|
||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||
}
|
||||
sessions.set(id, session)
|
||||
commands.set(id, command)
|
||||
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Latch.makeUnsafe()
|
||||
@@ -291,7 +291,7 @@ const layer = () =>
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
stream.write(chunk)
|
||||
session.size += chunk.length
|
||||
command.size += chunk.length
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -317,8 +317,8 @@ const layer = () =>
|
||||
|
||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.info.status !== "running") return
|
||||
session.info = produce(session.info, (draft) => {
|
||||
if (command.info.status !== "running") return
|
||||
command.info = produce(command.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
|
||||
// session still reports success rather than the removal NotFoundError. This runs before
|
||||
// command 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(session.done, session.info)
|
||||
yield* Deferred.succeed(command.done, command.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* removeSession(Shell.ID.make(oldest))
|
||||
yield* removeCommand(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 (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
})
|
||||
|
||||
session.timeout = (duration) =>
|
||||
command.timeout = (duration) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
session.timeoutFiber = undefined
|
||||
if (duration === 0 || session.info.status !== "running") return
|
||||
session.timeoutFiber = runFork(
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
command.timeoutFiber = undefined
|
||||
if (duration === 0 || command.info.status !== "running") return
|
||||
command.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* session.timeout(invocation.timeout)
|
||||
yield* command.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
@@ -370,16 +370,16 @@ const layer = () =>
|
||||
)
|
||||
|
||||
yield* bus.publish(Shell.Event.Created, { info })
|
||||
yield* Deferred.succeed(ready, session)
|
||||
yield* Deferred.succeed(ready, command)
|
||||
// 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(session.done).pipe(Effect.catch(() => Effect.void))
|
||||
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
|
||||
)
|
||||
|
||||
const session = yield* Deferred.await(ready)
|
||||
return session.info
|
||||
const command = yield* Deferred.await(ready)
|
||||
return command.info
|
||||
})
|
||||
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
|
||||
@@ -72,6 +72,7 @@ export const layer = Layer.effect(
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
sessionID: context.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
|
||||
@@ -375,7 +375,100 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let body: { messages?: unknown[] } | undefined
|
||||
@@ -415,7 +508,14 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.user("Inspect the screenshot."),
|
||||
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.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
|
||||
Message.tool({
|
||||
type: "tool-result",
|
||||
@@ -426,6 +526,12 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -434,7 +540,17 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
|
||||
).pipe(Effect.provide(client))
|
||||
|
||||
expect(body?.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
|
||||
{
|
||||
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: "assistant",
|
||||
content: "",
|
||||
@@ -457,6 +573,7 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
|
||||
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(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
|
||||
expect(yield* Fiber.join(first)).toEqual([moved, done])
|
||||
expect(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(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])
|
||||
expect((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -158,19 +158,17 @@ describe("Bus Session routing", () => {
|
||||
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
|
||||
const done = yield* bus.publish(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(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(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(
|
||||
Array.from(history)
|
||||
.filter((event): event is Event.Payload => !Bus.isSynced(event))
|
||||
.every((event) => !event.location),
|
||||
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -197,8 +195,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(Array.from(yield* Fiber.join(typed))).toEqual([expected])
|
||||
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
|
||||
expect(yield* Fiber.join(typed)).toEqual([expected])
|
||||
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -226,8 +224,8 @@ describe("Bus Session routing", () => {
|
||||
const done = yield* bus.publish(Done, {})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
|
||||
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
|
||||
expect(moved).not.toHaveProperty("location")
|
||||
}),
|
||||
)
|
||||
@@ -245,9 +243,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(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])
|
||||
expect(yield* Fiber.join(first)).toEqual([deleted, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -266,8 +264,8 @@ describe("Bus Session routing", () => {
|
||||
])
|
||||
const done = yield* bus.publish(Done, {})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
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])
|
||||
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
|
||||
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -295,8 +293,8 @@ describe("Bus Session routing", () => {
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(Exit.isFailure(single)).toBe(true)
|
||||
expect(Exit.isFailure(batch)).toBe(true)
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
|
||||
expect(yield* Fiber.join(first)).toEqual([before, after, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -327,8 +325,8 @@ describe("Bus Session routing", () => {
|
||||
{ publish: true },
|
||||
)
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
|
||||
const received = Array.from(yield* Fiber.join(second))
|
||||
expect(yield* Fiber.join(first)).toEqual([done])
|
||||
const received = 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 = Array.from(yield* Fiber.join(fiber)).map((event) =>
|
||||
const received = (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 = Array.from(yield* Fiber.join(fiber))
|
||||
const received = 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(Array.from(yield* Fiber.join(typed))).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
|
||||
expect(yield* Fiber.join(typed)).toEqual([event])
|
||||
expect(yield* Fiber.join(wildcard)).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -602,7 +602,7 @@ describe("Bus", () => {
|
||||
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect((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(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect((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(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect((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(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
|
||||
expect((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(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
|
||||
expect((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 = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
const items = 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 = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
const empty = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
|
||||
const drained = 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 = Array.from(yield* Fiber.join(fiber))
|
||||
const items = 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 = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
const items = 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 = Array.from(yield* Fiber.join(fiber))
|
||||
const items = 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,7 +84,8 @@ describe("Catalog", () => {
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = integrationID
|
||||
@@ -92,7 +93,8 @@ describe("Catalog", () => {
|
||||
)
|
||||
expect(yield* catalog.provider.available()).toEqual([])
|
||||
|
||||
yield* (yield* Credential.Service).create({
|
||||
const credentials = yield* Credential.Service
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "secret" }),
|
||||
})
|
||||
@@ -112,7 +114,8 @@ describe("Catalog", () => {
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = integrationID
|
||||
|
||||
@@ -330,10 +330,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("loads legacy file-based agents from config directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -407,10 +404,7 @@ Use native v2 fields.`,
|
||||
|
||||
for (const testCase of sourceCases()) {
|
||||
it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, testCase.source)
|
||||
@@ -445,10 +439,7 @@ Use native v2 fields.`,
|
||||
}
|
||||
|
||||
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "agents")
|
||||
@@ -485,10 +476,7 @@ Use native v2 fields.`,
|
||||
)
|
||||
|
||||
it.effect("ignores updates outside agent source directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "agents")
|
||||
|
||||
@@ -54,10 +54,7 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -166,10 +163,7 @@ Review files`,
|
||||
|
||||
for (const testCase of sourceCases()) {
|
||||
it.effect(`rebuilds commands when a source file is ${testCase.name}`, () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
@@ -212,10 +206,7 @@ Review files`,
|
||||
}
|
||||
|
||||
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
@@ -254,10 +245,7 @@ Review files`,
|
||||
)
|
||||
|
||||
it.effect("ignores updates outside command source directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).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 } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, 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,10 +77,7 @@ const provider = {
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("excludes home-level claude and agents directories when global is disabled", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(global, "home")
|
||||
@@ -120,10 +117,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("excludes global config reached through the project walk when global is disabled", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).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.
|
||||
@@ -156,10 +150,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads explicit file and content overrides in priority order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
@@ -194,10 +185,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("skips project configuration when project discovery is disabled", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
@@ -225,10 +213,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -261,10 +246,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("exposes filesystem updates under config roots through changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -296,10 +278,7 @@ 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.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -369,26 +348,24 @@ describe("Config", () => {
|
||||
}).pipe(Effect.provide(Config.testLayer())),
|
||||
)
|
||||
|
||||
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") }),
|
||||
}),
|
||||
]
|
||||
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") }),
|
||||
}),
|
||||
]
|
||||
|
||||
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(
|
||||
@@ -580,268 +557,241 @@ describe("Config", () => {
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
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 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 the v1 experimental subagent depth", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
}),
|
||||
)
|
||||
test("migrates the v1 experimental subagent depth", () => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
})
|
||||
|
||||
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 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 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 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 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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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 },
|
||||
})
|
||||
})
|
||||
|
||||
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" },
|
||||
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: {} },
|
||||
},
|
||||
"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", 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": {} },
|
||||
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,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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") },
|
||||
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.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" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
}).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.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
@@ -856,10 +806,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -888,10 +835,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -919,10 +863,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1033,10 +974,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("does not load legacy config.json files", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1055,10 +993,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("accepts $schema metadata without writing it into config files", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
@@ -1082,10 +1017,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads supported scalar and resource configuration", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1271,10 +1203,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("migrates the deprecated reference key into references", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1307,10 +1236,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("migrates v1 configuration when a v1-only key is present", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1482,10 +1408,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("ignores an invalid file while loading valid config values", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1511,10 +1434,7 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads global and ancestor configuration across the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const root = path.join(tmp.path, "repo")
|
||||
|
||||
@@ -2,13 +2,15 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
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"
|
||||
@@ -18,7 +20,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, Logger, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -35,6 +37,40 @@ 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", () =>
|
||||
@@ -51,7 +87,6 @@ describe("PluginSupervisor config", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows the built-in Plan agent to be disabled", () =>
|
||||
withLocation(
|
||||
{ agents: { plan: { disabled: true } } },
|
||||
@@ -270,7 +305,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(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
@@ -380,7 +415,7 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("loads user plugins before internal post plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
@@ -431,8 +466,8 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("unblocks flush when plugin activation fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
@@ -441,6 +476,47 @@ 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* () {
|
||||
@@ -448,16 +524,20 @@ 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.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.tap((tmp) =>
|
||||
Effect.promise(async () => {
|
||||
await prepare?.(tmp.path)
|
||||
|
||||
@@ -34,6 +34,41 @@ 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
|
||||
@@ -102,6 +137,14 @@ 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,6 +15,14 @@ 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(
|
||||
@@ -163,3 +171,41 @@ 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,18 +16,7 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
|
||||
}
|
||||
|
||||
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
|
||||
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()
|
||||
}),
|
||||
)
|
||||
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
|
||||
}
|
||||
|
||||
function alive(pid: number) {
|
||||
|
||||
@@ -201,7 +201,8 @@ describe("layer node", () => {
|
||||
Layer.provide(LayerNode.compile(result.hoisted)),
|
||||
) as unknown as Layer.Layer<App>
|
||||
const program = Effect.gen(function* () {
|
||||
return yield* (yield* App).run
|
||||
const app = yield* App
|
||||
return yield* app.run
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
expect(await Effect.runPromise(program)).toEqual(["Alice"])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import { describe, expect, spyOn } from "bun:test"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
@@ -14,6 +14,8 @@ 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(
|
||||
@@ -32,14 +34,13 @@ const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void)
|
||||
)
|
||||
|
||||
describe("FileSystemSearch", () => {
|
||||
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)
|
||||
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)
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const layer = FileSystemSearch.fffLayer.pipe(
|
||||
@@ -54,26 +55,22 @@ describe("FileSystemSearch", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
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),
|
||||
)
|
||||
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))
|
||||
}),
|
||||
)
|
||||
|
||||
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 {
|
||||
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
|
||||
// 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.
|
||||
await Bun.write(path.join(directory, "server-local.ts"), "server local")
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "server-local.ts"), "server local"))
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make(directory),
|
||||
@@ -92,37 +89,35 @@ describe("FileSystemSearch", () => {
|
||||
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
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))
|
||||
}),
|
||||
)
|
||||
|
||||
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")) } }),
|
||||
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")) } },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(observed).toBeUndefined()
|
||||
@@ -132,52 +127,52 @@ describe("FileSystemSearch", () => {
|
||||
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
|
||||
RelativePath.make(`src${path.sep}`),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
|
||||
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")) }),
|
||||
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")) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
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([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* 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"))
|
||||
@@ -196,47 +191,53 @@ 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), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
|
||||
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")) }),
|
||||
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")) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
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([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
@@ -246,9 +247,7 @@ describe("FileSystemSearch", () => {
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(2)
|
||||
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
prepare.mockRestore()
|
||||
cleanup.mockRestore()
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -244,14 +244,25 @@ 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. But it should still
|
||||
// be captured and included in the finish event's providerMetadata.
|
||||
// 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.
|
||||
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({
|
||||
@@ -305,6 +316,17 @@ 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(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
expect((yield* Fiber.join(created)).map((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,6 +49,29 @@ 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,7 +51,8 @@ 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 entries = yield* (yield* FileSystem.Service).list()
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const entries = yield* filesystem.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" },
|
||||
@@ -103,9 +104,8 @@ describe("FileSystem", () => {
|
||||
it.live("rejects lexical escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* (yield* FileSystem.Service)
|
||||
.read({ path: RelativePath.make("../outside.txt") })
|
||||
.pipe(Effect.exit)
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Hash,
|
||||
Layer,
|
||||
LayerMap,
|
||||
Option,
|
||||
RcMap,
|
||||
Schema,
|
||||
Stream,
|
||||
@@ -517,7 +518,8 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
const plugins = yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* (yield* PluginSupervisor.Service).flush
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
@@ -674,14 +676,21 @@ 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)
|
||||
}),
|
||||
),
|
||||
@@ -934,7 +943,8 @@ describe("LocationServiceMap", () => {
|
||||
})
|
||||
yield* plugins.activate([{ ...reviewer, version: "1" }])
|
||||
|
||||
expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
|
||||
description: "Reviews code",
|
||||
mode: "subagent",
|
||||
})
|
||||
|
||||
@@ -43,7 +43,8 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "hello.txt" })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
@@ -58,7 +59,8 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
@@ -70,7 +72,8 @@ describe("LocationMutation", () => {
|
||||
it.live("requires external-directory authorization for a relative lexical escape", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "../outside.txt" })
|
||||
const root = path.dirname(directory)
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "outside.txt"),
|
||||
@@ -117,7 +120,8 @@ describe("LocationMutation", () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "escape", "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
@@ -137,7 +141,8 @@ describe("LocationMutation", () => {
|
||||
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
|
||||
})
|
||||
|
||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
const mutation = yield* LocationMutation.Service
|
||||
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
absolute: path.join(directory, "linked", "new.txt"),
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
@@ -149,7 +154,8 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
resource: "new.txt",
|
||||
@@ -164,7 +170,8 @@ describe("LocationMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const root = outside
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "new.txt"),
|
||||
@@ -185,7 +192,8 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({ absolute: targetPath })
|
||||
expect(target.externalDirectory?.directory).toBe(outside)
|
||||
}).pipe(provide(directory)),
|
||||
@@ -197,7 +205,8 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: outside, kind: "file" })
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: path.dirname(outside),
|
||||
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
|
||||
@@ -212,7 +221,8 @@ describe("LocationMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const parent = path.dirname(targetPath)
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: parent,
|
||||
@@ -254,7 +264,8 @@ describe("LocationMutation", () => {
|
||||
it.live("resolves a tilde path as an external home target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "~/notes.md" })
|
||||
const absolute = path.resolve(Global.Path.home, "notes.md")
|
||||
expect(target).toMatchObject({
|
||||
absolute,
|
||||
@@ -270,7 +281,8 @@ describe("LocationMutation", () => {
|
||||
|
||||
it.live("treats a tilde path as in-location when the location is home", () =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "~/notes.md" })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.resolve(Global.Path.home, "notes.md"),
|
||||
resource: "notes.md",
|
||||
|
||||
@@ -49,6 +49,7 @@ 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 }>
|
||||
@@ -83,6 +84,12 @@ 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[],
|
||||
}
|
||||
@@ -132,6 +139,17 @@ 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
|
||||
@@ -313,6 +331,7 @@ 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),
|
||||
@@ -380,6 +399,43 @@ 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 }) =>
|
||||
@@ -1610,6 +1666,54 @@ 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>()
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("Npm.add", () => {
|
||||
|
||||
const first = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const mutableEntry = yield* npm.add(mutable, { refresh: true })
|
||||
const mutableEntry = yield* npm.add(mutable)
|
||||
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
|
||||
@@ -16,6 +16,7 @@ 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")
|
||||
@@ -40,7 +41,11 @@ 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 }[] }[] = []
|
||||
const prompts: {
|
||||
text: string
|
||||
files?: readonly { readonly uri: string }[]
|
||||
delivery?: "steer" | "queue"
|
||||
}[] = []
|
||||
yield* CommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
@@ -51,7 +56,7 @@ describe("CommandPlugin.Plugin", () => {
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, files: input.files })
|
||||
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
@@ -86,10 +91,50 @@ 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: expect.stringContaining("extra context"),
|
||||
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "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",
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Exit, Layer, Scope } 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,6 +11,8 @@ 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"
|
||||
@@ -18,6 +20,7 @@ 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,
|
||||
@@ -27,10 +30,68 @@ 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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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", ""] }])
|
||||
})
|
||||
@@ -70,20 +70,12 @@ describe("AppProcess", () => {
|
||||
"requireSuccess fails on non-zero exit",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
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")
|
||||
}
|
||||
}
|
||||
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)")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -105,15 +97,9 @@ 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 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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -313,18 +299,38 @@ 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('a'); process.exit(2)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect),
|
||||
.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)))),
|
||||
)
|
||||
|
||||
expect(lines.toSorted()).toEqual(["diagnostic-tail", "stderr-line", "stdout-line"])
|
||||
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)
|
||||
}
|
||||
}
|
||||
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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ describe("RepositoryCache", () => {
|
||||
await fs.writeFile(path.join(localPath, "stale.txt"), "stale")
|
||||
})
|
||||
|
||||
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const result = yield* cache.ensure({ reference: fixture.reference })
|
||||
|
||||
expect(result.status).toBe("cloned")
|
||||
expect(yield* exists(path.join(localPath, "stale.txt"))).toBe(false)
|
||||
@@ -94,7 +95,8 @@ describe("RepositoryCache", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
|
||||
|
||||
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const result = yield* cache.ensure({ reference: fixture.reference })
|
||||
|
||||
expect(result.status).toBe("cloned")
|
||||
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
|
||||
|
||||
@@ -20,6 +20,8 @@ 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"
|
||||
@@ -301,6 +303,54 @@ 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,6 +67,17 @@ 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()
|
||||
|
||||
@@ -119,7 +130,7 @@ describe("Session.create", () => {
|
||||
),
|
||||
)
|
||||
|
||||
liveIt.live("follows the directory's project identity established after creation", () =>
|
||||
projectIt.live("follows the directory's project identity established after creation", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -857,8 +857,7 @@ describe("SessionModelTransport", () => {
|
||||
test("records metadata-only lifecycle metrics", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
@@ -874,7 +873,11 @@ 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,8 +78,7 @@ 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,
|
||||
@@ -172,8 +171,9 @@ 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* (yield* Session.Service).active)).toEqual([sessionID])
|
||||
expect(Array.from(yield* session.active)).toEqual([sessionID])
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
|
||||
)
|
||||
|
||||
@@ -557,7 +557,7 @@ describe("Session.prompt", () => {
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
|
||||
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([message.id])
|
||||
expect(executionCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
|
||||
@@ -105,11 +105,9 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
|
||||
execute: () => new ForeignFailure({ message: "transport died" }) as never,
|
||||
}
|
||||
|
||||
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
|
||||
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
|
||||
expect(error).toBeInstanceOf(Tool.Error)
|
||||
expect((error as Tool.Error).message).toBe("transport died")
|
||||
expect(error.message).toBe("transport died")
|
||||
})
|
||||
|
||||
test("execute supports callable namespace tools", async () => {
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("Worktree", () => {
|
||||
{ directory: created.directory, strategy: "git" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
expect((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(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
expect((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>
|
||||
}
|
||||
|
||||
@@ -55,15 +55,13 @@ export const makeFormGroup = <
|
||||
params: { sessionID: Schema.String },
|
||||
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(formLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.list",
|
||||
summary: "List session forms",
|
||||
description: "Retrieve pending forms for a session.",
|
||||
}),
|
||||
),
|
||||
}).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,15 +90,13 @@ export const makePermissionGroup = <
|
||||
params: { sessionID: Session.ID },
|
||||
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.permission.list",
|
||||
summary: "List session permission requests",
|
||||
description: "Retrieve pending permission requests owned by a session.",
|
||||
}),
|
||||
),
|
||||
}).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", {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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,
|
||||
@@ -6,10 +8,10 @@ import {
|
||||
FormNotFoundError,
|
||||
InvalidRequestError,
|
||||
} from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location"
|
||||
|
||||
function missingForm(id: Form.ID) {
|
||||
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
|
||||
@@ -17,6 +19,8 @@ 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)))
|
||||
@@ -35,9 +39,16 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
|
||||
.handle(
|
||||
"session.form.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
const form = yield* Form.Service
|
||||
const forms = yield* form.list({ sessionID: ctx.params.sessionID })
|
||||
return { data: forms }
|
||||
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, () => []) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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 } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { response } from "../location"
|
||||
import { response, sessionRef, withLoadedLocationServices } from "../location"
|
||||
|
||||
function missingRequest(id: Permission.ID) {
|
||||
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
|
||||
@@ -13,6 +15,8 @@ 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,
|
||||
@@ -63,8 +67,13 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
|
||||
.handle(
|
||||
"session.permission.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
const permission = yield* Permission.Service
|
||||
return { data: yield* permission.forSession(ctx.params.sessionID) }
|
||||
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, () => []) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
@@ -26,6 +31,41 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
|
||||
})
|
||||
}
|
||||
|
||||
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
|
||||
|
||||
export function sessionRef(database: Context.Service.Shape<typeof Database.Service>, sessionID: unknown) {
|
||||
return Effect.gen(function* () {
|
||||
const id = yield* decodeSessionID(sessionID).pipe(
|
||||
Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
|
||||
)
|
||||
const row = yield* database.db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new SessionNotFoundError({ sessionID: id, message: `Session not found: ${id}` })
|
||||
return Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function withLoadedLocationServices<A, E>(
|
||||
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
|
||||
ref: Location.Ref,
|
||||
effect: Effect.Effect<A, E, LocationServices>,
|
||||
) {
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const context = yield* locations.contextEffectOption(ref)
|
||||
if (Option.isNone(context)) return Option.none<A>()
|
||||
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||
const query = new URL(request.url, "http://localhost").searchParams
|
||||
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { requestRef, type LocationServices } from "../location"
|
||||
import { requestRef, sessionRef, type LocationServices } from "../location"
|
||||
|
||||
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
FormLocationMiddleware,
|
||||
@@ -19,12 +13,10 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
error: [InvalidRequestError, SessionNotFoundError],
|
||||
}) {}
|
||||
|
||||
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
|
||||
|
||||
export const formLocationLayer = Layer.effect(
|
||||
FormLocationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const database = yield* Database.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
|
||||
return FormLocationMiddleware.of((effect) =>
|
||||
@@ -38,38 +30,8 @@ export const formLocationLayer = Layer.effect(
|
||||
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
|
||||
}
|
||||
|
||||
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new InvalidRequestError({
|
||||
message: "Invalid session ID",
|
||||
field: "sessionID",
|
||||
}),
|
||||
),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) {
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
}
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const ref = yield* sessionRef(database, route.params.sessionID)
|
||||
return yield* effect.pipe(Effect.provide(locations.get(ref)))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import type { LocationServices } from "../location"
|
||||
import { sessionRef, type LocationServices } from "../location"
|
||||
|
||||
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
SessionLocationMiddleware,
|
||||
@@ -19,48 +13,17 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
error: [InvalidRequestError, SessionNotFoundError],
|
||||
}) {}
|
||||
|
||||
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
|
||||
|
||||
export const sessionLocationLayer = Layer.effect(
|
||||
SessionLocationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const database = yield* Database.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
|
||||
return SessionLocationMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const route = yield* HttpRouter.RouteContext
|
||||
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new InvalidRequestError({
|
||||
message: "Invalid session ID",
|
||||
field: "sessionID",
|
||||
}),
|
||||
),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const ref = yield* sessionRef(database, route.params.sessionID)
|
||||
return yield* effect.pipe(Effect.provide(locations.get(ref)))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
@@ -276,6 +277,129 @@ it.live("serves the session view operation and missing-session error", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not load a location when reading pending session requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-pending-read-")))
|
||||
const handler = yield* ServerFetch.make({
|
||||
...options,
|
||||
config: {
|
||||
directory: config.path,
|
||||
project: false,
|
||||
content: JSON.stringify({ permissions: [{ action: "shell", resource: "*", effect: "ask" }] }),
|
||||
},
|
||||
})
|
||||
const created = (yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
}),
|
||||
).then((response) => response.json()),
|
||||
)) as { data: { id: string } }
|
||||
|
||||
const loaded = () =>
|
||||
Effect.promise(() =>
|
||||
handler(new Request("http://opencode.local/api/debug/location")).then(
|
||||
(response) => response.json() as Promise<unknown[]>,
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* loaded()).toEqual([])
|
||||
for (const resource of ["permission", "form"]) {
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`)),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`)),
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
}
|
||||
const global = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session/global/form", {
|
||||
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(global.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => global.json())).toEqual({ data: [] })
|
||||
expect(yield* loaded()).toEqual([])
|
||||
|
||||
const createdForm = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(`http://opencode.local/api/session/${created.data.id}/form`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "Test form", fields: [{ key: "answer", type: "string" }] }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(createdForm.status).toBe(200)
|
||||
|
||||
const forms = yield* Effect.promise(() =>
|
||||
handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`)),
|
||||
)
|
||||
expect(forms.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => forms.json())).toMatchObject({
|
||||
data: [{ title: "Test form" }],
|
||||
})
|
||||
expect(yield* loaded()).toHaveLength(1)
|
||||
|
||||
const globalForm = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session/global/form", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": encodeURIComponent(process.cwd()),
|
||||
},
|
||||
body: JSON.stringify({ title: "Global form", fields: [{ key: "answer", type: "string" }] }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(globalForm.status).toBe(200)
|
||||
|
||||
const globalForms = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session/global/form", {
|
||||
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(globalForms.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => globalForms.json())).toMatchObject({ data: [{ title: "Global form" }] })
|
||||
|
||||
// Agent permission policy is installed by plugin activation.
|
||||
expect((yield* ready(handler)).status).toBe(200)
|
||||
const createdPermission = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(`http://opencode.local/api/session/${created.data.id}/permission`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "per_pending_read", action: "shell", resources: ["pwd"] }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(createdPermission.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => createdPermission.json())).toEqual({
|
||||
data: { id: "per_pending_read", effect: "ask" },
|
||||
})
|
||||
|
||||
const permissions = yield* Effect.promise(() =>
|
||||
handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)),
|
||||
)
|
||||
expect(permissions.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => permissions.json())).toMatchObject({
|
||||
data: [{ id: "per_pending_read", sessionID: created.data.id, action: "shell", resources: ["pwd"] }],
|
||||
})
|
||||
expect(yield* loaded()).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
|
||||
// an aborted first request cannot interrupt layer construction and wedge every later request
|
||||
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"bench:diff": "bun run --conditions=browser script/bench-diff.tsx",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Diff Rendering
|
||||
|
||||
## Goal
|
||||
|
||||
Keep complete large diffs usable without hiding patches or requiring loading consent. Measure initial rendering and scrolling independently, and preserve first/last-line access, wrapping, gutters, navigation, syntax, and selection.
|
||||
|
||||
## Benchmark
|
||||
|
||||
Run from `packages/tui`:
|
||||
|
||||
```sh
|
||||
bun run bench:diff hunks 1000
|
||||
bun run bench:diff lines 50000
|
||||
DIFF_BENCH_WIDTH=80 bun run bench:diff lines 50000
|
||||
bun run bench:diff long 20000
|
||||
DIFF_BENCH_FILES=40 bun run bench:diff hunks 25
|
||||
```
|
||||
|
||||
The benchmark renders the production `PatchDiff`, with one containing box per file as in the viewer. It bypasses size guards, verifies access to the complete patch's last line, warms up once, and measures seven runs by default. `DIFF_BENCH_RUNS` overrides the measured run count; `DIFF_BENCH_SYNTAX=1` enables JSON highlighting. Report first-frame median/spread, scrolling p95/max, and process RSS separately.
|
||||
|
||||
Do not render hunks directly under the benchmark's scrollbox: that gives them direct-child viewport culling that the viewer's file cards do not have.
|
||||
|
||||
## Hypotheses
|
||||
|
||||
1. Per-hunk gutter-sync scheduling repeats whole-file work. Coalescing callbacks should remove that quadratic mount cost without changing rendered content.
|
||||
2. Gutter framebuffers and background painting scale with content height. Viewport-bounded painting should reduce giant-hunk scroll cost and native allocation without windowing text content.
|
||||
3. Repeated `lineInfo` reads decode full-document arrays. Correctly invalidated caching or range reads should reduce metadata allocation while preserving wrapped/source-line mappings.
|
||||
|
||||
## Results
|
||||
|
||||
Measured on the local machine with one warmup and three measured runs:
|
||||
|
||||
```sh
|
||||
DIFF_BENCH_RUNS=3 bun run bench:diff hunks 1000
|
||||
```
|
||||
|
||||
| Experiment | Ready Median | Ready Range | Scroll p95 | Scroll Max | Process RSS Max |
|
||||
| ---------------------------------------------- | ------------ | ------------ | ---------- | ---------- | --------------- |
|
||||
| Baseline, complete file-card subtree | 8762 ms | 7845-9027 ms | 33.36 ms | 70.61 ms | 1616 MB |
|
||||
| Coalesce whole-file gutter callbacks | 324 ms | 306-377 ms | 31.58 ms | 34.27 ms | 967 MB |
|
||||
| Read widths without resetting number/sign maps | 307 ms | 301-318 ms | 29.61 ms | 34.86 ms | 967 MB |
|
||||
|
||||
Keep callback coalescing: initial rendering improves substantially with the same complete content and tail access. It does not solve steady-state painting; scrolling remains about 32 ms p95. RSS includes JavaScript/native allocation and process high-water behavior across repeated mounts, not just gutter storage.
|
||||
|
||||
The narrower width pass removes redundant map replacement and unbounded argument-list maxima. Its timing difference overlaps the earlier spread; keep it for fewer data mutations, not as an independently proven speedup.
|
||||
|
||||
Before the renderer patch, the same component with a 50,000-line modified hunk at 160 columns measured 225 ms median ready time (215-239 ms), 22.55 ms scroll p95, and 25.52 ms scroll maximum across three runs. A 100,000-line modified hunk at 80 columns measured 530 ms ready time and 60.51 ms scroll p95 in a single measured probe; that single-run probe is diagnostic, not a stable benchmark comparison.
|
||||
|
||||
The benchmark's initial setup is not a performance result; production-component and full-viewer Drive verification are complementary. Record subsequent isolated experiments here before keeping them.
|
||||
|
||||
## Final Matrix
|
||||
|
||||
The unguarded V2 base `0d42e76006` and the final consumer patch use the same harness and fixtures. Each case warms up once and measures seven runs; all cases run sequentially without concurrent build/test activity. Syntax is disabled in this rendering benchmark; the production viewer tests and Drive fixtures exercise JSON highlighting separately.
|
||||
|
||||
| Fixture | Ready Median Before / After | Scroll p95 Before / After | Process RSS Max Before / After |
|
||||
| ------------------------------------- | --------------------------- | ------------------------- | ------------------------------ |
|
||||
| 1,000 original hunks, 160 columns | 9083 / 264 ms | 53.11 / 7.20 ms | 1600 / 871 MB |
|
||||
| 50,000 modified lines, split | 231 / 160 ms | 38.57 / 0.34 ms | 784 / 677 MB |
|
||||
| 50,000 modified lines, unified | 236 / 164 ms | 21.67 / 0.29 ms | 1029 / 674 MB |
|
||||
| 20,000-character modified line, split | 59 / 77 ms | 0.33 / 0.27 ms | 240 / 234 MB |
|
||||
| 40 files with 25 hunks each | 343 / 161 ms | 1.48 / 1.28 ms | 790 / 568 MB |
|
||||
| 100,000 modified lines, unified | 552 / 331 ms | 53.42 / 0.36 ms | 1532 / 1084 MB |
|
||||
|
||||
All cases expose their complete tails. Long markers can cross wrap rows, so that case reconstructs the visible final Code pane without interleaving the other side; the earlier contiguous-screen-marker check falsely rejected both revisions and was corrected identically before accepting these results.
|
||||
|
||||
The long-line opening adds about one frame (18 ms) to settle actual pane-width alignment. That is a measured correctness tradeoff, not a speedup. The many-hunk base also shows substantial spread (7879-17307 ms ready, 309 ms maximum scroll frame); the final ready range is 259-301 ms with a 19 ms maximum scroll frame. Measurements include process/compiler/allocator behavior and should not be presented as hard latency or memory guarantees.
|
||||
|
||||
The dependency patch retains complete native text/layout and selection owners. It bounds decoration raster allocation, paint visits, and source-ID readback, not source loading or full content-node virtualization. Native `0.5.9` platform binaries remain unchanged.
|
||||
@@ -0,0 +1,175 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { CodeRenderable, type Renderable, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { testRender, useKeyboard } from "@opentui/solid"
|
||||
import { For } from "solid-js"
|
||||
import { PatchDiff } from "../src/component/patch-diff"
|
||||
import { ConfigProvider } from "../src/config"
|
||||
import { ThemeProvider, useThemes } from "../src/context/theme"
|
||||
import { TestTuiContexts } from "../test/fixture/tui-environment"
|
||||
import { emptyThemeSource } from "../test/fixture/fixture"
|
||||
import { largeDiffFixture } from "../test/fixture/large-diff"
|
||||
import { createTuiResolvedConfig } from "../test/fixture/tui-runtime"
|
||||
|
||||
const kind = process.argv[2] ?? "hunks"
|
||||
if (kind !== "hunks" && kind !== "lines" && kind !== "long") throw new Error("Use hunks, lines, or long")
|
||||
const count = Number(process.argv[3] ?? (kind === "hunks" ? 1000 : 20000))
|
||||
const width = Number(process.env.DIFF_BENCH_WIDTH ?? 160)
|
||||
const files = Number(process.env.DIFF_BENCH_FILES ?? 1)
|
||||
const iterations = Number(process.env.DIFF_BENCH_RUNS ?? 7)
|
||||
const fixture = largeDiffFixture(kind, count)
|
||||
const results: { ready: number; scroll: number[]; rss: number }[] = []
|
||||
|
||||
for (let iteration = 0; iteration <= iterations; iteration++) {
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
function Content() {
|
||||
const themes = useThemes()
|
||||
const theme = themes.current
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "j") scroll?.scrollBy(1)
|
||||
})
|
||||
return (
|
||||
<scrollbox
|
||||
ref={(node: ScrollBoxRenderable) => (scroll = node)}
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={Array.from({ length: files }, (_, index) => index)}>
|
||||
{() => (
|
||||
<box>
|
||||
<PatchDiff
|
||||
diff={fixture.patch}
|
||||
view={width >= 100 ? "split" : "unified"}
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
filetype={process.env.DIFF_BENCH_SYNTAX ? "json" : undefined}
|
||||
syntaxStyle={themes.currentSyntax()}
|
||||
showLineNumbers
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
fg={theme.text.default}
|
||||
addedBg={theme.diff.background.added}
|
||||
removedBg={theme.diff.background.removed}
|
||||
contextBg={theme.diff.background.context}
|
||||
addedSignColor={theme.diff.highlight.added}
|
||||
removedSignColor={theme.diff.highlight.removed}
|
||||
lineNumberFg={theme.diff.lineNumber.text}
|
||||
lineNumberBg={theme.diff.background.context}
|
||||
addedLineNumberBg={theme.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={theme.diff.lineNumber.background.removed}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
)
|
||||
}
|
||||
const started = performance.now()
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Content />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width, height: 30 },
|
||||
)
|
||||
try {
|
||||
await app.flush()
|
||||
const ready = performance.now() - started
|
||||
if (!scroll) throw new Error("Missing patch viewport")
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
await app.flush()
|
||||
const code = kind === "long" ? codeNodes(app.renderer.root).at(-1) : undefined
|
||||
if (kind === "long" && !code) throw new Error("Missing diff code owner")
|
||||
const frame = app.captureCharFrame()
|
||||
// A long marker may cross wrap rows; reconstruct its pane without interleaving the other side.
|
||||
const visible = code
|
||||
? frame
|
||||
.split("\n")
|
||||
.map((line) => line.slice(code.x, code.x + code.width))
|
||||
.join("")
|
||||
.replace(/\s/g, "")
|
||||
: frame
|
||||
if (!visible.includes(fixture.tail)) throw new Error("The end of the full diff is not accessible")
|
||||
scroll.scrollTo(Math.floor(scroll.scrollHeight / 2))
|
||||
await app.flush()
|
||||
const frames: number[] = []
|
||||
for (let frame = 0; frame < 20; frame++) {
|
||||
const started = performance.now()
|
||||
app.mockInput.pressKey("j")
|
||||
await app.renderOnce()
|
||||
frames.push(performance.now() - started)
|
||||
}
|
||||
const result = { ready, scroll: frames, rss: process.memoryUsage().rss }
|
||||
if (iteration > 0) results.push(result)
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
iteration,
|
||||
warmup: iteration === 0,
|
||||
ready_ms: ready,
|
||||
scroll_p95_ms: percentile(frames, 0.95),
|
||||
rss_bytes: result.rss,
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
Bun.gc(true)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
kind,
|
||||
count,
|
||||
width,
|
||||
files,
|
||||
iterations,
|
||||
ready_median_ms: percentile(
|
||||
results.map((result) => result.ready),
|
||||
0.5,
|
||||
),
|
||||
ready_min_ms: percentile(
|
||||
results.map((result) => result.ready),
|
||||
0,
|
||||
),
|
||||
ready_max_ms: percentile(
|
||||
results.map((result) => result.ready),
|
||||
1,
|
||||
),
|
||||
scroll_p95_ms: percentile(
|
||||
results.flatMap((result) => result.scroll),
|
||||
0.95,
|
||||
),
|
||||
scroll_max_ms: percentile(
|
||||
results.flatMap((result) => result.scroll),
|
||||
1,
|
||||
),
|
||||
rss_max_bytes: percentile(
|
||||
results.map((result) => result.rss),
|
||||
1,
|
||||
),
|
||||
}),
|
||||
)
|
||||
console.log(
|
||||
`METRIC diff_ready_ms=${percentile(
|
||||
results.map((result) => result.ready),
|
||||
0.5,
|
||||
).toFixed(2)}`,
|
||||
)
|
||||
console.log(
|
||||
`METRIC diff_scroll_p95_ms=${percentile(
|
||||
results.flatMap((result) => result.scroll),
|
||||
0.95,
|
||||
).toFixed(2)}`,
|
||||
)
|
||||
|
||||
function percentile(values: number[], fraction: number) {
|
||||
return [...values].sort((left, right) => left - right)[Math.ceil((values.length - 1) * fraction)]
|
||||
}
|
||||
|
||||
function codeNodes(root: Renderable): CodeRenderable[] {
|
||||
return root instanceof CodeRenderable ? [root] : root.getChildren().flatMap(codeNodes)
|
||||
}
|
||||
+15
-10
@@ -68,7 +68,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
|
||||
import { DialogOpen, DialogOpenKey, moveOpenSession } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
|
||||
import { createPaneResize } from "./ui/pane-resize"
|
||||
@@ -507,7 +507,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
},
|
||||
})
|
||||
let openingOpen: Promise<SessionInfo[]> | undefined
|
||||
const [openSessions, setOpenSessions] = createSignal<SessionInfo[]>([])
|
||||
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
|
||||
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
|
||||
// the same problem on every refresh while still re-alerting if the state changes.
|
||||
@@ -719,14 +719,12 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
title: "Open session or project",
|
||||
category: "Session",
|
||||
slash: { name: "open", aliases: ["projects", "project"] },
|
||||
run: async () => {
|
||||
if (dialog.key === DialogOpenKey || openingOpen) return
|
||||
const previous = dialog.stack.at(-1)
|
||||
openingOpen = loadDialogOpen(data, client)
|
||||
const sessions = await openingOpen
|
||||
openingOpen = undefined
|
||||
if (dialog.stack.at(-1) !== previous) return
|
||||
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
|
||||
run: () => {
|
||||
if (dialog.key === DialogOpenKey) return
|
||||
dialog.replace(() => <DialogOpen sessions={openSessions()} onLoad={setOpenSessions} />, undefined, {
|
||||
key: DialogOpenKey,
|
||||
size: "large",
|
||||
})
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
@@ -1213,7 +1211,14 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
})
|
||||
})
|
||||
|
||||
event.on("session.moved", (evt) => {
|
||||
setOpenSessions((sessions) =>
|
||||
sessions.map((session) => (session.id !== evt.data.sessionID ? session : moveOpenSession(session, evt))),
|
||||
)
|
||||
})
|
||||
|
||||
event.on("session.deleted", (evt) => {
|
||||
setOpenSessions((sessions) => sessions.filter((session) => session.id !== evt.data.sessionID))
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
|
||||
const title = active?.id === evt.data.sessionID ? active.title : undefined
|
||||
route.navigate({ type: "home" })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
|
||||
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
@@ -25,18 +25,7 @@ export const DialogOpenKey = Symbol("DialogOpen")
|
||||
|
||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
||||
|
||||
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
|
||||
const [, sessions] = await Promise.all([
|
||||
data.project.sync().catch(() => {}),
|
||||
client.api.session
|
||||
.list({ limit: 50, order: "desc", parentID: null })
|
||||
.then((response) => response.data)
|
||||
.catch(() => [] as SessionInfo[]),
|
||||
])
|
||||
return sessions
|
||||
}
|
||||
|
||||
export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
@@ -51,6 +40,41 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
let closed = false
|
||||
onCleanup(() => {
|
||||
closed = true
|
||||
})
|
||||
const [recent] = createResource(() => {
|
||||
// A late read must not overwrite deletion or placement facts observed in flight.
|
||||
const changed = new Map<string, Extract<OpenCodeEvent, { type: "session.deleted" | "session.moved" }>>()
|
||||
const unsubscribe = client.event.listen((message) => {
|
||||
const event = message.details
|
||||
if (event.type === "session.deleted" || event.type === "session.moved") changed.set(event.data.sessionID, event)
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
return client.api.session
|
||||
.list({ limit: 50, order: "desc", parentID: null })
|
||||
.then((response) => {
|
||||
if (!closed)
|
||||
props.onLoad(
|
||||
response.data.flatMap((session) => {
|
||||
const event = changed.get(session.id)
|
||||
if (!event) return [session]
|
||||
if (event.type === "session.deleted") return []
|
||||
return [moveOpenSession(props.sessions.find((entry) => entry.id === session.id) ?? session, event)]
|
||||
}),
|
||||
)
|
||||
return true
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(unsubscribe)
|
||||
})
|
||||
const [projects] = createResource(() =>
|
||||
data.project.sync().then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
)
|
||||
|
||||
const [matched] = createResource(
|
||||
() => {
|
||||
@@ -154,12 +178,34 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onFilter={setFilter}
|
||||
emptyView={
|
||||
<Show when={!recent.loading && !projects.loading}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No recent sessions or projects</text>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
footer={
|
||||
<box>
|
||||
<Show when={recent.loading || projects.loading}>
|
||||
<Spinner color={theme.text.subdued}>Refreshing sessions and projects...</Spinner>
|
||||
</Show>
|
||||
<Show when={recent() === false || projects() === false}>
|
||||
<text fg={theme.text.feedback.error.default}>
|
||||
Could not refresh{" "}
|
||||
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
{recent.loading || projects.loading || matched.loading
|
||||
? "Searching sessions and projects..."
|
||||
: shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
@@ -177,6 +223,16 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function moveOpenSession(session: SessionInfo, event: Extract<OpenCodeEvent, { type: "session.moved" }>) {
|
||||
return {
|
||||
...session,
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID ?? session.projectID,
|
||||
subpath: event.data.subpath,
|
||||
time: { ...session.time, updated: Math.max(session.time.updated, event.created) },
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: number) {
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
|
||||
if (minutes < 1) return "now"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { createMemo, For, Show, splitProps } from "solid-js"
|
||||
import { createMemo, For, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { splitPatchHunks } from "../util/diff"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
@@ -20,6 +20,10 @@ export function PatchDiff(props: Props) {
|
||||
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
|
||||
const hunks = createMemo(() => splitPatchHunks(local.diff))
|
||||
const nodes = new Map<number, DiffRenderable>()
|
||||
let gutterFrame: number | undefined
|
||||
onCleanup(() => {
|
||||
if (gutterFrame !== undefined) cancelAnimationFrame(gutterFrame)
|
||||
})
|
||||
local.ref?.({
|
||||
hunks: () =>
|
||||
[...nodes.entries()]
|
||||
@@ -28,28 +32,31 @@ export function PatchDiff(props: Props) {
|
||||
.filter((node) => !node.isDestroyed),
|
||||
})
|
||||
const syncGutters = (attempt = 0) => {
|
||||
requestAnimationFrame(() => {
|
||||
// Registrations share one whole-file pass instead of repeating it for every hunk.
|
||||
if (gutterFrame !== undefined) return
|
||||
gutterFrame = requestAnimationFrame(() => {
|
||||
gutterFrame = undefined
|
||||
const sides = [...nodes.values()]
|
||||
.filter((item) => !item.isDestroyed)
|
||||
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
|
||||
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
|
||||
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
|
||||
const after = sides.map((side) =>
|
||||
Math.max(
|
||||
0,
|
||||
...[...side.getLineSigns()].filter(([line]) => line >= 0).map(([, sign]) => stringWidth(sign.after ?? "")),
|
||||
),
|
||||
const widths = sides.map((side) => {
|
||||
const width = { line: 0, sign: 0 }
|
||||
side.getLineNumbers().forEach((number, line) => {
|
||||
if (line >= 0) width.line = Math.max(width.line, number)
|
||||
})
|
||||
side.getLineSigns().forEach((sign, line) => {
|
||||
if (line >= 0) width.sign = Math.max(width.sign, stringWidth(sign.after ?? ""))
|
||||
})
|
||||
return { digits: width.line.toString().length, after: width.sign }
|
||||
})
|
||||
const max = widths.reduce(
|
||||
(max, width) => ({ digits: Math.max(max.digits, width.digits), after: Math.max(max.after, width.after) }),
|
||||
{ digits: 0, after: 0 },
|
||||
)
|
||||
const maxDigits = Math.max(...digits)
|
||||
const maxAfter = Math.max(...after)
|
||||
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
|
||||
if (!maxDigits) return
|
||||
sides.forEach((side) => {
|
||||
const index = sides.indexOf(side)
|
||||
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
|
||||
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
|
||||
side.setLineNumbers(lineNumbers[index])
|
||||
side.setLineSigns(signs)
|
||||
if (!max.digits && attempt < 2) return syncGutters(attempt + 1)
|
||||
if (!max.digits) return
|
||||
sides.forEach((side, index) => {
|
||||
side.setLineSign(-1, { after: " ".repeat(max.after + max.digits - widths[index].digits) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,8 +284,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
selection = option
|
||||
if (!moved) return
|
||||
if (
|
||||
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
|
||||
store.filter.length > 0
|
||||
!props.preserveSelection &&
|
||||
(props.current === undefined || props.focusCurrent === false || store.filter.length > 0)
|
||||
)
|
||||
return
|
||||
scrollAfterLayout(false, option.value)
|
||||
|
||||
@@ -8,6 +8,241 @@ import path from "node:path"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const projects = Promise.withResolvers<Response>()
|
||||
const refresh = Promise.withResolvers<Response>()
|
||||
const events = createEventStream()
|
||||
const cachedSession = {
|
||||
id: "ses_cached",
|
||||
title: "Cached session",
|
||||
projectID: "proj_fixture",
|
||||
location: { directory: "/fixture" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let requests = 0
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") {
|
||||
requests++
|
||||
requested.resolve()
|
||||
if (requests === 1) return response.promise
|
||||
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
|
||||
if (requests > 4) return new Response("Unavailable", { status: 503 })
|
||||
return json({ data: [cachedSession], cursor: {} })
|
||||
}
|
||||
if (url.pathname === "/api/project") return projects.promise
|
||||
return undefined
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({ animations: false }), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
await ready.promise
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await requested.promise
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("Search sessions")
|
||||
expect(setup.captureCharFrame()).toContain("Refreshing")
|
||||
projects.resolve(
|
||||
json([
|
||||
{
|
||||
id: "proj_fixture",
|
||||
canonical: "/fixture",
|
||||
name: "Fixture project",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
expect(requests).toBe(1)
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
||||
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).not.toContain("Fixture project")
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
|
||||
expect(setup.captureCharFrame()).not.toContain("Disposed")
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Cached"))
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
|
||||
events.emit({
|
||||
id: "evt_deleted",
|
||||
created: 1,
|
||||
type: "session.deleted",
|
||||
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
|
||||
data: { sessionID: "ses_cached" },
|
||||
})
|
||||
await setup.waitForFrame((frame) => !frame.includes("Cached"))
|
||||
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
|
||||
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
|
||||
expect(setup.captureCharFrame()).not.toContain("Cached")
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
|
||||
expect(setup.captureCharFrame()).not.toContain("Cached")
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
response.resolve(json({ data: [], cursor: {} }))
|
||||
projects.resolve(json([]))
|
||||
refresh.resolve(json({ data: [], cursor: {} }))
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["dismissed", "refreshing"])(
|
||||
"Ctrl-O retains committed movement of a cached-only session while %s",
|
||||
async (phase) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const refresh = Promise.withResolvers<Response>()
|
||||
const metadata = Promise.withResolvers<Response>()
|
||||
const destinationRequested = Promise.withResolvers<void>()
|
||||
const events = createEventStream()
|
||||
const cached = {
|
||||
id: "ses_cached_move",
|
||||
title: "Cached movement",
|
||||
projectID: "proj_old",
|
||||
location: { directory: "/fixture/old" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let requests = 0
|
||||
const locations: string[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") {
|
||||
if (url.searchParams.has("parentID")) {
|
||||
const parent = url.searchParams.get("parentID")
|
||||
if (parent && parent !== "null") return json({ data: [], cursor: {} })
|
||||
}
|
||||
return requests++ === 0
|
||||
? json({ data: [cached], cursor: {} })
|
||||
: refresh.promise.then((response) => response.clone())
|
||||
}
|
||||
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
|
||||
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
|
||||
if (url.pathname === `/api/session/${cached.id}/inbox` || url.pathname === `/api/session/${cached.id}/permission`)
|
||||
return json({ data: [] })
|
||||
if (url.pathname === "/api/project")
|
||||
return json(
|
||||
["old", "new"].map((name) => ({
|
||||
id: `proj_${name}`,
|
||||
canonical: `/fixture/${name}`,
|
||||
name: name === "old" ? "Old" : "New",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
})),
|
||||
)
|
||||
if (url.pathname === "/api/location") {
|
||||
const query = url.searchParams.get("location[directory]") ?? ""
|
||||
locations.push(query)
|
||||
if (query.includes("/fixture/new")) {
|
||||
destinationRequested.resolve()
|
||||
return json({
|
||||
directory: "/fixture/new",
|
||||
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
|
||||
})
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await ready.promise
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
|
||||
if (phase === "refreshing") {
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
|
||||
}
|
||||
events.emit({
|
||||
id: "evt_cached_moved",
|
||||
created: 3,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: cached.id, seq: 1, version: 1 },
|
||||
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
|
||||
})
|
||||
if (phase === "dismissed") {
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
refresh.resolve(new Response("Unavailable", { status: 503 }))
|
||||
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
|
||||
}
|
||||
if (phase === "refreshing") {
|
||||
await setup.waitForFrame((frame) =>
|
||||
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
|
||||
)
|
||||
refresh.resolve(json({ data: [cached], cursor: {} }))
|
||||
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
|
||||
}
|
||||
await setup.waitForFrame((frame) =>
|
||||
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
|
||||
)
|
||||
expect(
|
||||
setup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes(cached.title)),
|
||||
).toContain("New")
|
||||
locations.length = 0
|
||||
setup.mockInput.pressEnter()
|
||||
await destinationRequested.promise
|
||||
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
|
||||
} finally {
|
||||
refresh.resolve(json({ data: [], cursor: {} }))
|
||||
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
await server.stop()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const titles: string[] = []
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { once } from "node:events"
|
||||
import { CliRenderEvents, TextAttributes } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { DialogOpen, DialogOpenKey } from "../../../src/component/dialog-open"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
@@ -85,7 +88,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID })
|
||||
expect(fixture.location.ref).toEqual(remote)
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -131,7 +134,7 @@ test("shows the current project and opens its root", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("waits for sessions before showing the populated picker", async () => {
|
||||
test("shows projects while sessions refresh and preserves the selected project", async () => {
|
||||
let resolveSessions!: (response: Response) => void
|
||||
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
|
||||
const fixture = await renderOpen((url) => {
|
||||
@@ -157,8 +160,9 @@ test("waits for sessions before showing the populated picker", async () => {
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.renderOnce()
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Second project") && frame.includes("Refreshing"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
|
||||
resolveSessions(
|
||||
json({
|
||||
@@ -177,8 +181,6 @@ test("waits for sessions before showing the populated picker", async () => {
|
||||
}),
|
||||
)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
@@ -188,6 +190,240 @@ test("waits for sessions before showing the populated picker", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("keeps a filtered selection visible after refresh with query reset %s", async (reset) => {
|
||||
const sessions = Promise.withResolvers<Response>()
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return sessions.promise
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_first",
|
||||
canonical: "/tmp/opencode/first",
|
||||
name: "First shared project",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_second",
|
||||
canonical: "/tmp/opencode/second",
|
||||
name: "Second shared project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
return undefined
|
||||
})
|
||||
const selectedTitle = () =>
|
||||
fixture.app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.filter((span) => span.attributes & TextAttributes.BOLD)
|
||||
.map((span) => span.text)
|
||||
.join("")
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Second shared project") && frame.includes("Refreshing"))
|
||||
await fixture.app.mockInput.typeText("shared")
|
||||
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
await fixture.app.waitForFrame(() => selectedTitle().includes("Second shared project"))
|
||||
|
||||
sessions.resolve(
|
||||
json({
|
||||
data: Array.from({ length: 12 }, (_, index) => ({
|
||||
...recentSession,
|
||||
id: `ses_shared_${index}`,
|
||||
title: "shared",
|
||||
time: { created: 1, updated: index + 3 },
|
||||
})),
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Open") && !frame.includes("Refreshing"))
|
||||
// Selection reveal runs on FRAME; the following paint must show the selected row.
|
||||
const frame = once(fixture.app.renderer, CliRenderEvents.FRAME)
|
||||
fixture.app.renderer.requestRender()
|
||||
await frame
|
||||
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
|
||||
expect(selectedTitle()).toContain("Second shared project")
|
||||
|
||||
if (reset) {
|
||||
await fixture.app.mockInput.typeText(" project")
|
||||
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
|
||||
expect(selectedTitle()).not.toContain("Second shared project")
|
||||
}
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({
|
||||
type: "home",
|
||||
location: { directory: `/tmp/opencode/${reset ? "first" : "second"}` },
|
||||
})
|
||||
} finally {
|
||||
sessions.resolve(json({ data: [], cursor: {} }))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
const recentSession = {
|
||||
id: "ses_recent",
|
||||
projectID: "proj_recent",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Recent session",
|
||||
location: { directory: "/fixture" },
|
||||
}
|
||||
|
||||
test("sessions remain selectable while projects are still loading", async () => {
|
||||
const projects = Promise.withResolvers<Response>()
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [recentSession], cursor: {} })
|
||||
if (url.pathname === "/api/project") return projects.promise
|
||||
return undefined
|
||||
})
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
|
||||
} finally {
|
||||
projects.resolve(json([]))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows hydrated sessions immediately without waiting for either read", async () => {
|
||||
const sessions = Promise.withResolvers<Response>()
|
||||
const projects = Promise.withResolvers<Response>()
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/session") return sessions.promise
|
||||
if (url.pathname === "/api/project") return projects.promise
|
||||
return undefined
|
||||
},
|
||||
({ data }) => data.session.remember(recentSession),
|
||||
)
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
|
||||
} finally {
|
||||
sessions.resolve(json({ data: [], cursor: {} }))
|
||||
projects.resolve(json([]))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps an uncached moved session in the first successful refresh", async () => {
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const destination = { directory: "/fixture/destination" }
|
||||
const fixture = await renderOpen((url) => (url.pathname === "/api/session" ? response.promise : undefined))
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Refreshing"))
|
||||
fixture.emit({
|
||||
id: "evt_uncached_move",
|
||||
created: 3,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: recentSession.id, seq: 1, version: 1 },
|
||||
data: { sessionID: recentSession.id, location: destination, projectID: "proj_destination" },
|
||||
})
|
||||
// The following event supplies an ordered-stream receipt barrier without hydrating metadata.
|
||||
fixture.emit({
|
||||
id: "evt_move_received",
|
||||
created: 4,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: recentSession.id, seq: 2, version: 1 },
|
||||
data: { sessionID: recentSession.id },
|
||||
})
|
||||
await fixture.app.waitFor(() => fixture.data.session.status(recentSession.id) === "running")
|
||||
expect(fixture.data.session.get(recentSession.id)).toBeUndefined()
|
||||
response.resolve(
|
||||
json({
|
||||
data: [
|
||||
{ ...recentSession, location: destination, projectID: "proj_destination", time: { created: 1, updated: 3 } },
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(recentSession.title) && !frame.includes("Refreshing"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
|
||||
expect(fixture.location.ref).toEqual(destination)
|
||||
} finally {
|
||||
response.resolve(json({ data: [], cursor: {} }))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the previous recent list usable when reopening fails to refresh", async () => {
|
||||
let requests = 0
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
return requests++ === 0
|
||||
? json({ data: [recentSession], cursor: {} })
|
||||
: new Response("Unavailable", { status: 503 })
|
||||
return undefined
|
||||
})
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && !frame.includes("Refreshing"))
|
||||
fixture.app.mockInput.pressEscape()
|
||||
await fixture.app.waitForFrame((frame) => !frame.includes("Recent session"))
|
||||
fixture.open()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("Recent session")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows an initial loading shell instead of reporting an empty list", async () => {
|
||||
const sessions = Promise.withResolvers<Response>()
|
||||
const projects = Promise.withResolvers<Response>()
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return sessions.promise
|
||||
if (url.pathname === "/api/project") return projects.promise
|
||||
return undefined
|
||||
})
|
||||
try {
|
||||
await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("Search sessions and projects") && frame.includes("Refreshing"),
|
||||
)
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("No items available")
|
||||
await fixture.app.mockInput.typeText("missing")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Searching sessions and projects"))
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("No matches")
|
||||
sessions.resolve(json({ data: [], cursor: {} }))
|
||||
projects.resolve(json([]))
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("No matches") && !frame.includes("Refreshing"))
|
||||
} finally {
|
||||
sessions.resolve(json({ data: [], cursor: {} }))
|
||||
projects.resolve(json([]))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports both refresh failures while keeping hydrated sessions usable", async () => {
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/session" || url.pathname === "/api/project")
|
||||
return new Response("Unavailable", { status: 503 })
|
||||
return undefined
|
||||
},
|
||||
({ data }) => data.session.remember(recentSession),
|
||||
)
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions and projects"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("Recent session")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("option arrows jump between sections", async () => {
|
||||
const handler: FetchHandler = (url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
@@ -291,20 +527,21 @@ async function renderOpen(
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let data!: ReturnType<typeof useData>
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
let open!: () => void
|
||||
|
||||
function Probe() {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const [sessions, setSessions] = createSignal<SessionInfo[]>([])
|
||||
route = useRoute()
|
||||
location = useLocation()
|
||||
data = useData()
|
||||
storage = useStorage()
|
||||
onMount(
|
||||
() =>
|
||||
void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
|
||||
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
|
||||
),
|
||||
)
|
||||
open = () =>
|
||||
dialog.replace(() => <DialogOpen sessions={sessions()} onLoad={setSessions} />, undefined, {
|
||||
key: DialogOpenKey,
|
||||
size: "large",
|
||||
})
|
||||
onMount(() => void Promise.resolve(beforeOpen?.({ data, location })).then(open))
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -344,6 +581,8 @@ async function renderOpen(
|
||||
|
||||
return {
|
||||
app,
|
||||
emit: events.emit,
|
||||
open: () => open(),
|
||||
get route() {
|
||||
return route
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
BoxRenderable,
|
||||
CodeRenderable,
|
||||
DiffRenderable,
|
||||
ImageRenderable,
|
||||
MouseButton,
|
||||
@@ -32,6 +33,7 @@ import { createDialogApi } from "../../../src/plugin/api"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { diffImageFixture } from "../../fixture/diff-image"
|
||||
import { largeDiffFixture } from "../../fixture/large-diff"
|
||||
|
||||
test("closing the diff viewer returns to the route it opened from", async () => {
|
||||
const viewer = await renderDiffViewer([])
|
||||
@@ -80,6 +82,154 @@ test("shows an error instead of an empty diff when loading fails", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test.each([80, 160])(
|
||||
"large diffs render automatically and expose their complete content at %i columns",
|
||||
async (width) => {
|
||||
const file = largeDiffFixture("lines", 20000)
|
||||
const viewer = await renderDiffViewer([file], { width, height: 30 })
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
|
||||
expect(findDiffs(viewer.app.renderer.root)).toHaveLength(1)
|
||||
expect(viewer.commands.has("diff.load_patch")).toBe(false)
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("Large diff hidden")
|
||||
viewer.app.mockInput.pressKey("G")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
|
||||
expect(
|
||||
viewer.app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes(file.tail)),
|
||||
).toMatch(/20000\s+/)
|
||||
viewer.app.mockInput.pressKey("g")
|
||||
viewer.app.mockInput.pressKey("g")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
|
||||
expect(findScrollBox(viewer.app.renderer.root)!.scrollTop).toBe(0)
|
||||
viewer.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await viewer.app.waitFor(() => viewer.current().type !== "plugin")
|
||||
expect(viewer.current()).toEqual(startRoute)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("all original hunks stay available for forward, reverse, and end navigation", async () => {
|
||||
const file = largeDiffFixture("hunks", 1000)
|
||||
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes('"column_1"'))
|
||||
await viewer.app.flush()
|
||||
const hunks = findDiffs(viewer.app.renderer.root)
|
||||
expect(hunks).toHaveLength(1000)
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.flush()
|
||||
expect(hunks[1].y).toBeGreaterThanOrEqual(scroll.viewport.y + 2)
|
||||
expect(hunks[1].y).toBeLessThanOrEqual(scroll.viewport.y + 3)
|
||||
viewer.app.mockInput.pressKey("[")
|
||||
await viewer.app.flush()
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
viewer.app.mockInput.pressKey("G")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
|
||||
expect(
|
||||
viewer.app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes(file.tail)),
|
||||
).toMatch(/39985\s+/)
|
||||
viewer.app.mockInput.pressKey("g")
|
||||
viewer.app.mockInput.pressKey("g")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes('"column_1"'))
|
||||
expect(findDiffs(viewer.app.renderer.root)[0]).toBe(hunks[0])
|
||||
expect(findDiffs(viewer.app.renderer.root).at(-1)).toBe(hunks.at(-1))
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("long wrapped lines retain their tails through narrow and wide resizing", async () => {
|
||||
const file = largeDiffFixture("long", 20000)
|
||||
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("xxxx"))
|
||||
await viewer.app.flush()
|
||||
viewer.app.mockInput.pressKey("G")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
|
||||
viewer.app.resize(80, 24)
|
||||
await viewer.app.flush()
|
||||
expect(findDiffs(viewer.app.renderer.root)[0].view).toBe("unified")
|
||||
viewer.app.mockInput.pressKey("G")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
|
||||
viewer.app.resize(160, 30)
|
||||
await viewer.app.flush()
|
||||
expect(findDiffs(viewer.app.renderer.root)[0].view).toBe("split")
|
||||
viewer.app.mockInput.pressKey("G")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
|
||||
viewer.app.mockInput.pressKey("g")
|
||||
viewer.app.mockInput.pressKey("g")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("xxxx"))
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("large file review and source replacement keep the full patch usable", async () => {
|
||||
const file = largeDiffFixture("lines", 20000)
|
||||
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
|
||||
viewer.app.mockInput.pressKey("m")
|
||||
await viewer.app.flush()
|
||||
expect(findDiffs(viewer.app.renderer.root)).toHaveLength(0)
|
||||
viewer.app.mockInput.pressKey("m")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
|
||||
const previous = findDiffs(viewer.app.renderer.root)[0]
|
||||
viewer.app.mockInput.pressKey("d")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Switch source"))
|
||||
viewer.app.mockInput.pressArrow("down")
|
||||
viewer.app.mockInput.pressEnter()
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Main branch") && frame.includes('"column_0"'))
|
||||
expect(previous.isDestroyed).toBe(true)
|
||||
viewer.app.mockInput.pressKey("G")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("large diff selection survives offscreen scrolling and same-view resizing", async () => {
|
||||
const viewer = await renderDiffViewer([largeDiffFixture("lines", 20000)], { width: 160, height: 30 })
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
|
||||
await viewer.app.flush()
|
||||
const code = findDiffs(viewer.app.renderer.root)[0]
|
||||
.getChildren()
|
||||
.flatMap((side) => side.getChildren())
|
||||
.find((node) => node instanceof CodeRenderable)
|
||||
if (!code) throw new Error("Missing diff text owner")
|
||||
const first = code.content.split("\n")[0]
|
||||
await viewer.app.mockMouse.drag(code.x + 2, code.y, code.x + first.length - 1, code.y)
|
||||
const selection = viewer.app.renderer.getSelection()
|
||||
expect(selection?.getSelectedText()).toBe(first.slice(2))
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
scroll.scrollTo(15000)
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.renderer.getSelection()).toBe(selection)
|
||||
expect(selection?.getSelectedText()).toBe(first.slice(2))
|
||||
viewer.app.resize(180, 30)
|
||||
await viewer.app.flush()
|
||||
expect(selection?.getSelectedText()).toBe(first.slice(2))
|
||||
scroll.scrollTo(0)
|
||||
await viewer.app.flush()
|
||||
expect(selection?.getSelectedText()).toBe(first.slice(2))
|
||||
expect(code.isDestroyed).toBe(false)
|
||||
viewer.app.renderer.clearSelection()
|
||||
expect(viewer.app.renderer.getSelection()).toBeNull()
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("uses the active location when opened outside a session", async () => {
|
||||
const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } })
|
||||
try {
|
||||
|
||||
@@ -32,7 +32,6 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
await using temporary = await tmpdir()
|
||||
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
|
||||
const [active, setActive] = createSignal("second")
|
||||
const [animations, setAnimations] = createSignal(false)
|
||||
const [newTab, setNewTab] = createSignal(false)
|
||||
const [preview, setPreview] = createSignal(false)
|
||||
const settings: Info = { tabs: { enabled: true } }
|
||||
@@ -91,11 +90,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<box width="100%" height="100%">
|
||||
<SessionTabs
|
||||
controller={controller}
|
||||
orientation={orientation}
|
||||
animations={animations()}
|
||||
/>
|
||||
<SessionTabs controller={controller} orientation={orientation} animations={false} />
|
||||
</box>
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
@@ -141,7 +136,6 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
}
|
||||
|
||||
for (const attention of ["question", "permission"] as const) {
|
||||
setAnimations(false)
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
|
||||
await app.renderOnce()
|
||||
@@ -171,84 +165,39 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
const dim = glow()
|
||||
expect(dim).toBeGreaterThan(0)
|
||||
expect(dim).toBeLessThan(full)
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
await app.waitForFrame(() => glow() > dim && glow() < full)
|
||||
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
await app.waitForFrame(() => glow() > dim && glow() < full)
|
||||
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
|
||||
|
||||
setStatus(EMPTY_SESSION_TAB_STATUS)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const glyph = "\u2022"
|
||||
for (const unread of ["activity", "error"] as const) {
|
||||
setAnimations(false)
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
|
||||
await app.renderOnce()
|
||||
const color = () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === glyph)?.fg
|
||||
expect(color()?.toInts()).toEqual(
|
||||
const color = app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === glyph)?.fg
|
||||
expect(color?.toInts()).toEqual(
|
||||
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
|
||||
)
|
||||
const brightness = () => {
|
||||
const value = color()
|
||||
return value ? value.r + value.g + value.b : undefined
|
||||
}
|
||||
const initial = brightness()!
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
await app.renderOnce()
|
||||
expect(active()).toBe("first")
|
||||
expect(status().unread).toBeUndefined()
|
||||
expect(app.captureCharFrame()).toContain(`${glyph} First`)
|
||||
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
|
||||
const peak = brightness()!
|
||||
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
|
||||
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
|
||||
expect(app.captureCharFrame()).toContain(" First")
|
||||
expect(app.captureCharFrame()).not.toContain(`${glyph} First`)
|
||||
}
|
||||
|
||||
setAnimations(false)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
|
||||
await app.waitForFrame((frame) => frame.includes("? First"))
|
||||
|
||||
await config.update((draft) => {
|
||||
draft.tabs.indicators = "numbers"
|
||||
})
|
||||
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("1 First")
|
||||
await config.update((draft) => {
|
||||
draft.tabs.indicators = "status"
|
||||
})
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
|
||||
await app.waitForFrame((frame) => frame.includes(`${SPINNER_FRAMES[0]} First`))
|
||||
|
||||
setAnimations(false)
|
||||
setStatus(EMPTY_SESSION_TAB_STATUS)
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export function largeDiffFixture(kind: "hunks" | "lines" | "long", count: number) {
|
||||
const file = {
|
||||
file: "snapshot.json",
|
||||
additions: kind === "long" ? 1 : count,
|
||||
deletions: kind === "long" ? 1 : count,
|
||||
status: "modified" as const,
|
||||
}
|
||||
const prefix = "--- a/snapshot.json\n+++ b/snapshot.json\n"
|
||||
if (kind === "long") {
|
||||
return {
|
||||
...file,
|
||||
patch: `${prefix}@@ -1 +1 @@\n-${"x".repeat(count)}_OLD_END\n+${"y".repeat(count)}_NEW_END\n`,
|
||||
tail: "_NEW_END",
|
||||
}
|
||||
}
|
||||
if (kind === "lines") {
|
||||
return {
|
||||
...file,
|
||||
patch:
|
||||
prefix +
|
||||
`@@ -1,${count} +1,${count} @@\n` +
|
||||
Array.from({ length: count }, (_, index) => `- "column_${index}": ${index},\n`).join("") +
|
||||
Array.from({ length: count }, (_, index) => `+ "column_${index}": ${index + 1},\n`).join(""),
|
||||
tail: `"column_${count - 1}"`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...file,
|
||||
patch:
|
||||
prefix +
|
||||
Array.from({ length: count }, (_, index) => {
|
||||
const start = index * 40 + 1
|
||||
return (
|
||||
`@@ -${start},25 +${start},25 @@\n` +
|
||||
Array.from({ length: 12 }, (_, offset) => ` "column_${start + offset}": ${start + offset},\n`).join("") +
|
||||
`- "column_${start + 12}": ${start + 12},\n+ "column_${start + 12}": ${start + 13},\n` +
|
||||
Array.from(
|
||||
{ length: 12 },
|
||||
(_, offset) => ` "column_${start + 13 + offset}": ${start + 13 + offset},\n`,
|
||||
).join("")
|
||||
)
|
||||
}).join(""),
|
||||
tail: `"column_${(count - 1) * 40 + 25}"`,
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const tree = yield* reify({ dir, add: [pkg] })
|
||||
if (isMutable(parsed)) refreshed.add(pkg)
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) {
|
||||
const installed = yield* installedName(pkg, dir, parsedName)
|
||||
|
||||
@@ -220,11 +220,22 @@ const layer = Layer.effect(
|
||||
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const streams =
|
||||
options?.includeStderr === true
|
||||
? yield* handle.stderr.pipe(
|
||||
Stream.broadcastN({ n: 2, capacity: 16 }),
|
||||
Effect.map((copies) => ({
|
||||
source: Stream.merge(handle.stdout, copies[0]),
|
||||
diagnostics: copies[1],
|
||||
})),
|
||||
)
|
||||
: { source: handle.stdout, diagnostics: handle.stderr }
|
||||
const stderrFiber = yield* Effect.forkScoped(
|
||||
collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
|
||||
collectStream(streams.diagnostics, options?.maxErrorBytes).pipe(
|
||||
Effect.map((x) => x.buffer.toString("utf8")),
|
||||
),
|
||||
)
|
||||
const source = options?.includeStderr === true ? handle.all : handle.stdout
|
||||
const lines = source.pipe(
|
||||
const lines = streams.source.pipe(
|
||||
Stream.decodeText,
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
|
||||
@@ -40,13 +40,15 @@ Project-specific configuration can use either form:
|
||||
/home/user/projects/my-app/.opencode/opencode.json(c)
|
||||
```
|
||||
|
||||
When OpenCode starts, it searches for configuration files from the current
|
||||
directory upward to the project root. It merges direct `opencode.json(c)` files
|
||||
from the project root toward the current directory, then does the same for
|
||||
files inside `.opencode` directories. A `.opencode` config therefore overrides
|
||||
every direct config, even when the direct config is closer to the current
|
||||
directory. Avoid mixing the two forms across one project hierarchy unless this
|
||||
precedence is intentional.
|
||||
During ordinary project discovery, OpenCode searches for configuration files
|
||||
from the current Location directory through every ancestor to the filesystem
|
||||
root, including directories above the detected project or repository root. It
|
||||
merges direct `opencode.json(c)` files from the farthest ancestor toward the
|
||||
current directory, then does the same for files inside `.opencode` directories.
|
||||
A discovered `.opencode` config therefore overrides every discovered direct
|
||||
config, even when the direct config is closer to the current directory. Avoid
|
||||
mixing the two forms across one directory hierarchy unless this precedence is
|
||||
intentional.
|
||||
|
||||
For example, consider a monorepo with OpenCode started from
|
||||
`/home/user/projects/acme/packages/web`:
|
||||
|
||||
@@ -231,6 +231,18 @@ Use permission actions to hide or deny a server's tools without stopping its con
|
||||
}
|
||||
```
|
||||
|
||||
## Session context
|
||||
|
||||
When OpenCode invokes an MCP tool on behalf of a session, it includes the invoking
|
||||
session's ID in `CallToolRequest.params._meta.sessionID`. This applies to direct tool
|
||||
calls and Code Mode over both stdio and Streamable HTTP.
|
||||
|
||||
The ID is request metadata, not a tool argument, so it does not appear in the
|
||||
model-visible tool schema. Treat it as an opaque correlation value: it identifies the
|
||||
invoking OpenCode session rather than the MCP transport session, can be absent for
|
||||
calls without session context, and must not be used by itself for authentication or
|
||||
authorization. Remote MCP servers receive the raw ID and may log or retain it.
|
||||
|
||||
## Manage servers
|
||||
|
||||
OpenCode interfaces can add servers to project or global configuration, list
|
||||
|
||||
@@ -93,9 +93,10 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
|
||||
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
|
||||
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
|
||||
|
||||
Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and
|
||||
Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay
|
||||
pinned. Changes to unwatched local dependencies may still require restarting OpenCode.
|
||||
Changes under watched config directories reload automatically. Server startup loads cached package plugins immediately,
|
||||
then refreshes unpinned npm and Git plugins in the background. A refreshed package becomes active the next time the server
|
||||
starts. Exact npm versions and full Git commit hashes stay pinned. Changes to unwatched local dependencies may still require
|
||||
restarting OpenCode.
|
||||
|
||||
```sh
|
||||
touch .opencode/plugins/concise.ts
|
||||
|
||||
Reference in New Issue
Block a user