mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 21:16:10 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a079b874 | ||
|
|
3b63ee92ec | ||
|
|
596b5d5a19 | ||
|
|
3698847d8b | ||
|
|
d837ffe70f | ||
|
|
b1d7dd82fc | ||
|
|
e409567428 | ||
|
|
9538c2171f |
@@ -1073,9 +1073,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2212,27 +2212,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-darwin-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-iJKwj+aq0w0TYZu0xafb5Nj5eBObZcKc4+qHCpj5EAQBRCid3Z8nROmiwdO+aDDeqwTYzfG53u+MU3urEtiweQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-8Wo6B6VhFXu5Lq/HDXQCkxCJF4svJ3OPU+WcotQJuLT9/pXzik40WjTKVn3HECKiqQ3ryQS4ZxpnJIxlNDcpmg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-Tt+qGxp5VJZKmw54XdzebE6a+0QVA5Sk8KWUDHfWXn4ley6tvpMZowrG2/O6TpKHP6XdA/bHm+Ewetyk7BO2Zg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-p62IQHNFFQUzet57HMQndNQZtunDc0peSHudBoatCTV68QYKsD5gYjAK/BW1XY0zUoJM9Jk7BJnPBJPv1s5EYQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-qvQgkAdLSa1+SeMhMNBZ7zx7OlVi8yMsV+DDGTDrcc9sWXDzGJEKUq/R9XqqAag64Sloc12YA5oUYDKKj4E2VA=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-JIkwBP6yLhh4ViTR53kx/NCY5w1ocTcKMdWidpObKLVLIkBat97y085teSvgkh+RFuJnUaJK7114XgdEONCjSg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-M1BPZZ7eLE/nfnS7uypyIxg5JKfkkZSkehszISrQOf8zQl3+j3kOPy17RqSCJKZboGFQwmg8rwlaxGNxA6sfVQ=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-GFQ1GCA4FQg/MVpO1jt53X4fTaYsQTfW1EkOpQqmt/Rvls/oSklpElxh4ww5/giHEVnNxvcqX8+RG9U5DCmLpg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-NKFUOLwBfd59koYiPgeqrP4hZERnG6B7+RnH2DNO3dAnIAl/7dISuOwWw/VacYBck4ToG0ZrbhBMTeBB2i5DqQ=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Lw+wxniAxIqnZz9vFPwl8EsH33HZGnyukQTU/4dI01BwNKbx2hcxuFMjrHFP/MkqhvwmFXcvGSGfJrG8VLwFoA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-wtq+offTD/Hxd9KXW5eVjeBgtBiJiXakkk1Jo/8ULX2fvG+trtdaAHr0DrlHsRv/xyUMfxPBaHRwPe4Gk8+8sw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -6564,10 +6564,6 @@
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
|
||||
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
|
||||
|
||||
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -7454,30 +7450,6 @@
|
||||
|
||||
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"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=="],
|
||||
@@ -8276,8 +8248,6 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -87,7 +87,7 @@ const layer = Layer.effect(
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const selectable = (agent: Info | undefined) =>
|
||||
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
||||
|
||||
@@ -134,7 +134,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("Catalog.finalize")(function* () {
|
||||
notify: Effect.fn("Catalog.notify")(function* () {
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
|
||||
draft: (draft) => ({
|
||||
add: (definition) => draft.set(definition.name, definition),
|
||||
}),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const info = (definition: Definition) =>
|
||||
Info.make({
|
||||
|
||||
@@ -316,16 +316,15 @@ export const layer = (options?: Options) =>
|
||||
}
|
||||
})
|
||||
|
||||
const reload = Effect.fn("Config.reload")(() =>
|
||||
reloadLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
const reload = Effect.fn("Config.reload")(
|
||||
function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
},
|
||||
(effect) => reloadLock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(updates).pipe(
|
||||
|
||||
@@ -292,18 +292,13 @@ function normalizeMcpTimeout(
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
||||
const recognized = Object.entries(ConfigMCP.Timeout.fields).filter(([key]) => own(value, key))
|
||||
if (Object.keys(value).length && !recognized.length) {
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
recognized.forEach((key) => {
|
||||
const leaf = decodeEncoded(
|
||||
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
||||
value[key],
|
||||
[...path, key],
|
||||
diagnostics,
|
||||
)
|
||||
recognized.forEach(([key, field]) => {
|
||||
const leaf = decodeEncoded(field, value[key], [...path, key], diagnostics)
|
||||
if (leaf === undefined) return
|
||||
overlay(timeout, key, leaf, [...path, key], diagnostics)
|
||||
})
|
||||
|
||||
@@ -32,19 +32,7 @@ type PathAction =
|
||||
| typeof ReadTool.name
|
||||
| typeof EditTool.name
|
||||
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
|
||||
const agentKeys = new Set([
|
||||
"model",
|
||||
"variant",
|
||||
"request",
|
||||
"system",
|
||||
"description",
|
||||
"mode",
|
||||
"hidden",
|
||||
"color",
|
||||
"steps",
|
||||
"disabled",
|
||||
"permissions",
|
||||
])
|
||||
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.agent",
|
||||
|
||||
@@ -83,26 +83,25 @@ export const Plugin = define({
|
||||
),
|
||||
)
|
||||
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(
|
||||
function* (file?: string) {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
},
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
|
||||
@@ -151,26 +151,25 @@ export const Plugin = define({
|
||||
return skills
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(
|
||||
function* (file?: string) {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
},
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
|
||||
|
||||
@@ -25,19 +25,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let current: readonly string[] = []
|
||||
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
|
||||
const state = State.create<Data, Draft>({
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "location-watcher-policy",
|
||||
initial: () => ({ ignore: [] }),
|
||||
draft: (draft) => ({
|
||||
add: (ignore) => draft.ignore.push(...ignore),
|
||||
list: () => draft.ignore,
|
||||
}),
|
||||
finalize: (draft) =>
|
||||
Effect.sync(() => {
|
||||
current = [...draft.list()]
|
||||
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
|
||||
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
|
||||
})
|
||||
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
|
||||
listener: (ignore: readonly string[]) => Effect.Effect<void>,
|
||||
@@ -56,7 +52,7 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
current: () => current,
|
||||
current: () => state.get().ignore,
|
||||
observe,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
|
||||
+101
-107
@@ -328,7 +328,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
|
||||
@@ -378,117 +378,111 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.exit,
|
||||
)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const persistence = yield* Effect.sync(() => {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
return attempt.label ?? implementation?.label?.(exit.value)
|
||||
}).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.exit,
|
||||
)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}, Effect.uninterruptible)
|
||||
|
||||
const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<string, unknown>) {
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
|
||||
const persistence = yield* createCredential({
|
||||
const persistence = yield* createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
}),
|
||||
)
|
||||
})
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
}, Effect.uninterruptible)
|
||||
|
||||
const scrub = Effect.fnUntraced(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
|
||||
@@ -6,7 +6,21 @@ 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"
|
||||
import {
|
||||
Cause,
|
||||
Context,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
FiberSet,
|
||||
Latch,
|
||||
Layer,
|
||||
Schema,
|
||||
Scope,
|
||||
Semaphore,
|
||||
Stream,
|
||||
Types,
|
||||
} from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -615,8 +629,9 @@ export const layer = (options?: Options) =>
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
|
||||
const reconcile = Effect.fnUntraced(function* (next: Draft) {
|
||||
const servers = new Map(next.list())
|
||||
const reconcileLock = Semaphore.makeUnsafe(1)
|
||||
const reconcile = Effect.fnUntraced(function* () {
|
||||
const servers = state.get().servers
|
||||
if (!applied && entries.size === 0) {
|
||||
for (const [name, server] of servers) {
|
||||
entries.set(name, {
|
||||
@@ -677,7 +692,7 @@ export const layer = (options?: Options) =>
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
),
|
||||
)
|
||||
const state = State.create<Data, Draft>({
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "mcp",
|
||||
initial: () => ({
|
||||
servers: new Map(
|
||||
@@ -702,7 +717,12 @@ export const layer = (options?: Options) =>
|
||||
},
|
||||
remove: (server) => draft.servers.delete(ServerName.make(server)),
|
||||
}),
|
||||
finalize: reconcile,
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
|
||||
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
yield* exit
|
||||
}),
|
||||
})
|
||||
|
||||
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||
|
||||
@@ -26,6 +26,7 @@ export type Info = Reference.Info
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Types.DeepMutable<Source>>
|
||||
materialized: Map<string, Info>
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
@@ -47,61 +48,71 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Draft>({
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "reference",
|
||||
initial: () => ({ sources: new Map() }),
|
||||
initial: () => ({ sources: new Map(), materialized: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
}),
|
||||
finalize: (draft) =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
for (const [name, source] of draft.list()) {
|
||||
if (source.type === "local") {
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: source.path,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
materialized.set(
|
||||
prepare: (data) => {
|
||||
for (const [name, source] of data.sources) {
|
||||
if (source.type === "local") {
|
||||
data.materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
|
||||
path: source.path,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
data.materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
for (const info of state.get().materialized.values()) {
|
||||
const source = info.source
|
||||
if (source.type !== "git") continue
|
||||
yield* cache
|
||||
.ensure({
|
||||
reference: Repository.parseRemote(source.repository),
|
||||
branch: source.branch,
|
||||
refresh: true,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name: info.name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* bus.publish(Reference.Event.Updated, {})
|
||||
}),
|
||||
@@ -111,7 +122,7 @@ const layer = Layer.effect(
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
return Array.from(state.get().materialized.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -48,6 +48,7 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
export interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown and hook-removed calls
|
||||
* fail individually through the same seam.
|
||||
@@ -364,9 +365,11 @@ export const layer = Layer.effect(
|
||||
tools
|
||||
.execute({ ...input, definitions: hooked })
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
retry,
|
||||
executeTool,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { InstructionState } from "../instruction-state.js"
|
||||
@@ -171,7 +171,7 @@ const layer = Layer.effect(
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
|
||||
const sessionID = first.session.id
|
||||
let assistantMessageID = SessionMessage.ID.create()
|
||||
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
|
||||
const retry = yield* SessionRunnerRetry.make(bus, sessionID)
|
||||
let initial: SessionContext.Loaded | undefined = first
|
||||
let recoverOverflow = true
|
||||
let recoverContinuation = true
|
||||
@@ -217,6 +217,15 @@ const layer = Layer.effect(
|
||||
agent: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
prepared,
|
||||
retry: (cause, error, proposed) =>
|
||||
retry.decide({
|
||||
cause,
|
||||
error,
|
||||
agent: loaded.agent.id,
|
||||
model: loaded.model.ref,
|
||||
hook: prepared.retry,
|
||||
retry: proposed,
|
||||
}),
|
||||
recoverContinuation,
|
||||
recoverOverflow: Effect.suspend(() =>
|
||||
recoverOverflow && compaction.enabled()
|
||||
@@ -227,18 +236,17 @@ const layer = Layer.effect(
|
||||
const completed = yield* SessionStep.Outcome.$match(outcome, {
|
||||
Completed: (outcome) => Effect.succeed(outcome.needsContinuation),
|
||||
Retry: (outcome) =>
|
||||
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() =>
|
||||
bus
|
||||
.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
|
||||
.pipe(Effect.andThen(outcome.cause)),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
retry.wait({
|
||||
decision: outcome.decision,
|
||||
error: outcome.error,
|
||||
assistantMessageID,
|
||||
}),
|
||||
Continue: Effect.fnUntraced(function* (outcome) {
|
||||
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() => outcome.cause),
|
||||
)
|
||||
yield* retry.wait({
|
||||
decision: outcome.decision,
|
||||
error: outcome.error,
|
||||
assistantMessageID,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Clock, Effect, Iterable } from "effect"
|
||||
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
@@ -45,9 +46,6 @@ export interface StepRecord {
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
type NonEmptyContent = readonly [Tool.Content, ...Tool.Content[]]
|
||||
|
||||
const nonEmpty = (content: ReadonlyArray<Tool.Content>): NonEmptyContent | undefined =>
|
||||
content.length > 0 ? (content as NonEmptyContent) : undefined
|
||||
|
||||
const stringify = (value: unknown) => {
|
||||
if (typeof value === "string") return value
|
||||
try {
|
||||
@@ -58,10 +56,7 @@ const stringify = (value: unknown) => {
|
||||
}
|
||||
|
||||
const hostedContent = (result: ToolResultValue): NonEmptyContent => {
|
||||
if (result.type === "content") {
|
||||
const content = nonEmpty(result.value)
|
||||
if (content !== undefined) return content
|
||||
}
|
||||
if (result.type === "content" && isReadonlyArrayNonEmpty(result.value)) return result.value
|
||||
return [{ type: "text", text: stringify(result.value) }]
|
||||
}
|
||||
|
||||
@@ -563,12 +558,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
: result.content === undefined
|
||||
? []
|
||||
: [...result.content]
|
||||
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
|
||||
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
|
||||
yield* bus.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
id,
|
||||
content: [content[0], ...content.slice(1)],
|
||||
content,
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
export * as SessionRunnerRetry from "./retry.js"
|
||||
|
||||
import { AIError } from "@opencode-ai/ai"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Duration, Effect, Schedule } from "effect"
|
||||
import { Clock, Duration, Effect, Pull, Schedule } from "effect"
|
||||
import { Bus } from "../../bus.js"
|
||||
import type { PluginHooks } from "../../plugin/hooks.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
|
||||
export interface Input {
|
||||
interface Input {
|
||||
readonly cause: AIError
|
||||
readonly error: SessionError.Error
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly hook: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
|
||||
readonly retry: boolean
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
readonly retry: true
|
||||
readonly attempt: number
|
||||
readonly delay: number
|
||||
}
|
||||
|
||||
export function isRetryable(error: AIError) {
|
||||
@@ -55,22 +67,58 @@ const retryAfter = (input: Input) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
|
||||
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
|
||||
Schedule.jittered,
|
||||
Schedule.setInputType<Input>(),
|
||||
Schedule.modifyDelay(({ input, duration: delay }) => {
|
||||
const minimum = retryAfter(input)
|
||||
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
|
||||
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
|
||||
}),
|
||||
Schedule.tap((metadata) =>
|
||||
bus.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: metadata.input.assistantMessageID,
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: metadata.input.error,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
|
||||
Schedule.jittered,
|
||||
Schedule.setInputType<Input>(),
|
||||
Schedule.modifyDelay(({ input, duration: delay }) => {
|
||||
const minimum = retryAfter(input)
|
||||
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
|
||||
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
|
||||
}),
|
||||
)
|
||||
|
||||
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const step = yield* Schedule.toStep(schedule)
|
||||
let attempt = 1
|
||||
const decide = (input: Input) =>
|
||||
Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const next = yield* step(now, input).pipe(Pull.catchDone(() => Effect.succeed(undefined)))
|
||||
if (!next) return { retry: false as const }
|
||||
const [, duration] = next
|
||||
attempt++
|
||||
const delay = Math.ceil(Duration.toMillis(duration))
|
||||
const event: PluginHooks.Domains["session"]["retry"] = {
|
||||
sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
error: input.error,
|
||||
attempt,
|
||||
decision: input.retry ? { retry: true, delay } : { retry: false },
|
||||
}
|
||||
yield* input.hook(event)
|
||||
if (!event.decision.retry) return event.decision
|
||||
const normalized =
|
||||
Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay
|
||||
return { retry: true as const, attempt, delay: normalized }
|
||||
})
|
||||
const wait = (input: {
|
||||
readonly decision: Decision
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: SessionError.Error
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const scheduled = yield* Clock.currentTimeMillis
|
||||
yield* bus.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
attempt: input.decision.attempt,
|
||||
at: scheduled + input.decision.delay,
|
||||
error: input.error,
|
||||
})
|
||||
const remaining = Math.max(0, scheduled + input.decision.delay - (yield* Clock.currentTimeMillis))
|
||||
yield* Effect.sleep(Duration.millis(remaining))
|
||||
})
|
||||
return { decide, wait }
|
||||
})
|
||||
|
||||
@@ -31,8 +31,11 @@ import { SessionRunnerRetry } from "./retry.js"
|
||||
|
||||
export type Outcome = Data.TaggedEnum<{
|
||||
Completed: { readonly needsContinuation: boolean }
|
||||
Retry: { readonly cause: AIError; readonly error: SessionError.Error }
|
||||
Continue: { readonly cause: AIError; readonly error: SessionError.Error }
|
||||
Retry: { readonly error: SessionError.Error; readonly decision: SessionRunnerRetry.Decision }
|
||||
Continue: {
|
||||
readonly error: SessionError.Error
|
||||
readonly decision: SessionRunnerRetry.Decision
|
||||
}
|
||||
RecoverFull: {}
|
||||
Compacted: {}
|
||||
}>
|
||||
@@ -44,6 +47,11 @@ interface Input {
|
||||
readonly agent: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly prepared: SessionModelRequest.Prepared
|
||||
readonly retry: (
|
||||
cause: AIError,
|
||||
error: SessionError.Error,
|
||||
retry: boolean,
|
||||
) => Effect.Effect<{ readonly retry: false } | SessionRunnerRetry.Decision>
|
||||
readonly recoverContinuation: boolean
|
||||
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
|
||||
readonly recoverOverflow: Effect.Effect<boolean>
|
||||
@@ -161,10 +169,21 @@ export const make = Effect.gen(function* () {
|
||||
!recorded.outputStarted
|
||||
)
|
||||
return Outcome.RecoverFull()
|
||||
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
|
||||
const retry =
|
||||
llmFailure && llmError && !isContextOverflowFailure(llmFailure)
|
||||
? yield* restore(
|
||||
input.retry(
|
||||
llmFailure,
|
||||
llmError,
|
||||
SessionRunnerRetry.isRetryable(llmFailure) ||
|
||||
(recorded.outputStarted && isInterruptedStream(llmFailure)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
if (llmFailure && llmError && retry?.retry && !recorded.outputStarted) {
|
||||
// Retry state projects onto the existing assistant, even before it has produced output.
|
||||
yield* publisher.startAssistant()
|
||||
return Outcome.Retry({ cause: llmFailure, error: llmError })
|
||||
return Outcome.Retry({ error: llmError, decision: retry })
|
||||
}
|
||||
if (llmError) yield* publisher.failAssistant(llmError)
|
||||
|
||||
@@ -221,20 +240,15 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
}
|
||||
|
||||
// After durable output, recovery continues instead of replaying: the
|
||||
// partial assistant message is already persisted history. Any failure
|
||||
// the pre-output gate would retry is continued here, plus interrupted
|
||||
// streams, whose read failures may carry delivery states the retry
|
||||
// policy rejects for full resends.
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
(isInterruptedStream(llmFailure) || SessionRunnerRetry.isRetryable(llmFailure)) &&
|
||||
retry?.retry &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
)
|
||||
return Outcome.Continue({ cause: llmFailure, error: llmError })
|
||||
return Outcome.Continue({ error: llmError, decision: retry })
|
||||
|
||||
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { map } from "effect/Array"
|
||||
import path from "path"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app.js"
|
||||
@@ -309,21 +310,13 @@ function sanitizeToolState(id: string, state: SessionMessage.ToolState): Session
|
||||
return {
|
||||
...state,
|
||||
input: { redacted: `tool-input:${id}` },
|
||||
content: [
|
||||
sanitizeToolContent(id, state.content[0]),
|
||||
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
|
||||
],
|
||||
content: map(state.content, (item) => sanitizeToolContent(id, item)),
|
||||
metadata: meta,
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
input: { redacted: `tool-input:${id}` },
|
||||
content: state.content
|
||||
? [
|
||||
sanitizeToolContent(id, state.content[0]),
|
||||
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
|
||||
]
|
||||
: undefined,
|
||||
content: state.content ? map(state.content, (item) => sanitizeToolContent(id, item)) : undefined,
|
||||
metadata: meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ const layer = Layer.effect(
|
||||
draft.skills.delete(ID.make(id))
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
||||
+88
-94
@@ -1,9 +1,9 @@
|
||||
export * as State from "./state.js"
|
||||
|
||||
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
|
||||
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
|
||||
|
||||
/**
|
||||
* A replayable transform applied to a draft during reload.
|
||||
* A replayable transform applied to a draft while deriving state.
|
||||
*
|
||||
* Domain drafts expose readable and writable state while preserving concise
|
||||
* plugin/config code. Transforms synchronously rebuild derived state.
|
||||
@@ -16,13 +16,14 @@ export interface Registration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers and applies a scoped transform. Closing the owning Scope removes
|
||||
* the transform and reloads the materialized state.
|
||||
* Registers a scoped transform and invalidates the derived state. Closing the
|
||||
* owning Scope removes the transform. Reads synchronously replay pending changes.
|
||||
*/
|
||||
export type Transform<DraftApi> = (
|
||||
transform: TransformCallback<DraftApi>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
/** Invalidates the snapshot after captured inputs change and coalesces notifications. */
|
||||
export type Reload = () => Effect.Effect<void>
|
||||
|
||||
export interface Transformable<DraftApi> {
|
||||
@@ -33,7 +34,7 @@ export interface Transformable<DraftApi> {
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly flush: boolean
|
||||
readonly reloads: Set<Reload>
|
||||
readonly notifications: Set<Reload>
|
||||
}
|
||||
|
||||
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
||||
@@ -41,17 +42,24 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
|
||||
})
|
||||
const reloadDebounce = 500
|
||||
|
||||
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
|
||||
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active && options.flush !== false) return yield* effect
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
|
||||
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
|
||||
batch.active = false
|
||||
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
|
||||
return yield* exit
|
||||
})
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active && options.flush !== false) return yield* restore(effect)
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
|
||||
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
|
||||
batch.active = false
|
||||
const notifications = batch.flush
|
||||
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
|
||||
: []
|
||||
// Accepted writes are not rolled back: one failed observer must not hide
|
||||
// the other states' changes, or replace the batch body's failure.
|
||||
yield* Exit.asVoidAll([exit, ...notifications])
|
||||
return yield* exit
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const inherit = Effect.fnUntraced(function* () {
|
||||
@@ -65,124 +73,110 @@ export interface Options<State, DraftApi> {
|
||||
readonly initial: () => State
|
||||
/** Wraps mutable state in a domain-specific draft API. */
|
||||
readonly draft: MakeDraft<State, DraftApi>
|
||||
/** Synchronously completes derived data after ordered transform replay. */
|
||||
readonly prepare?: (state: State) => void
|
||||
/**
|
||||
* Runs after the rebuilt state becomes visible. Update events published here
|
||||
* act as read barriers: subscribers refetching on the event observe the
|
||||
* committed state.
|
||||
* Observes accepted changes outside the read path. Batched writes notify at
|
||||
* batch completion; reloads debounce notifications. Reads never run this hook.
|
||||
* Resource reconciliation owns its execution scope and coordination.
|
||||
*/
|
||||
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
|
||||
readonly notify?: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
||||
/** Returns the latest accepted state, replaying stale inputs synchronously. */
|
||||
readonly get: () => State
|
||||
}
|
||||
|
||||
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
|
||||
let state = options.initial()
|
||||
let transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||
let generation = 0
|
||||
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
|
||||
let dirty = false
|
||||
let requestedAt = 0
|
||||
let running = false
|
||||
let closed = false
|
||||
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
let pending: Deferred.Deferred<void> | undefined
|
||||
|
||||
const commit = Effect.fn("State.commit")(function* (next: State) {
|
||||
state = next
|
||||
if (options.finalize) yield* options.finalize(options.draft(next))
|
||||
})
|
||||
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const get = () => {
|
||||
if (!dirty || closed) return state
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms) {
|
||||
yield* Effect.sync(() => {
|
||||
transform.run(api)
|
||||
})
|
||||
}
|
||||
yield* commit(next)
|
||||
transforms.forEach((transform) => transform.run(api))
|
||||
options.prepare?.(next)
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
}
|
||||
|
||||
const notify = Effect.fn("State.notify")(function* () {
|
||||
if (closed) return
|
||||
get()
|
||||
if (options.notify) yield* options.notify()
|
||||
})
|
||||
|
||||
const materializeReload = () => semaphore.withPermit(materialize())
|
||||
|
||||
const rebuild = (): Effect.Effect<void> =>
|
||||
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const clock = yield* Clock.Clock
|
||||
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
|
||||
if (remaining > 0) yield* Effect.sleep(remaining)
|
||||
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
|
||||
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish(done)
|
||||
|
||||
const target = generation
|
||||
const exit = yield* materializeReload().pipe(Effect.exit)
|
||||
const completed = waiters.filter((waiter) => waiter.generation <= target)
|
||||
waiters = waiters.filter((waiter) => waiter.generation > target)
|
||||
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
if (generation > target) return yield* rebuild()
|
||||
running = false
|
||||
// Release scheduling ownership before observers run: an observer may
|
||||
// request and await another reload without joining this notification.
|
||||
pending = undefined
|
||||
yield* notify().pipe(Deferred.into(done))
|
||||
})
|
||||
|
||||
const reload = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const done = Deferred.makeUnsafe<void>()
|
||||
const clock = yield* Clock.Clock
|
||||
generation++
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
waiters.push({ generation, done })
|
||||
if (!running) {
|
||||
running = true
|
||||
yield* rebuild().pipe(Effect.forkDetach)
|
||||
}
|
||||
yield* Deferred.await(done)
|
||||
})
|
||||
const changed = (debounce: boolean) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
if (debounce) dirty = true
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.notifications.add(notify)
|
||||
return
|
||||
}
|
||||
if (!debounce) return yield* restore(notify())
|
||||
|
||||
const clock = yield* Clock.Clock
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
// No yields between choosing the burst's completion and claiming it.
|
||||
const done = pending ?? Deferred.makeUnsafe<void>()
|
||||
if (!pending) {
|
||||
pending = done
|
||||
yield* publish(done).pipe(Effect.forkDetach)
|
||||
}
|
||||
yield* restore(Deferred.await(done))
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
get: () => state,
|
||||
get,
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
||||
const scope = yield* Scope.Scope
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { run: update }
|
||||
let active = true
|
||||
const dispose = Effect.uninterruptible(
|
||||
semaphore.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (!active) return Effect.void
|
||||
active = false
|
||||
transforms = transforms.filter((item) => item !== transform)
|
||||
return Effect.gen(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
// Detached debounced reloads must also stay quiet after teardown.
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.reloads.add(materializeReload)
|
||||
return
|
||||
}
|
||||
yield* materialize()
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
transforms = [...transforms, transform]
|
||||
Effect.suspend(() => {
|
||||
if (!transforms.delete(transform)) return Effect.void
|
||||
dirty = true
|
||||
return changed(false)
|
||||
}),
|
||||
)
|
||||
transforms.add(transform)
|
||||
dirty = true
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) batch.reloads.add(materializeReload)
|
||||
else yield* materializeReload()
|
||||
yield* changed(false)
|
||||
return { dispose }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
reload,
|
||||
reload: () => changed(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ const layer = Layer.effect(
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () =>
|
||||
notify: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
|
||||
@@ -40,24 +40,7 @@ const AgentSchema = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
|
||||
const KNOWN_KEYS = new Set([
|
||||
"name",
|
||||
"model",
|
||||
"variant",
|
||||
"prompt",
|
||||
"description",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"mode",
|
||||
"hidden",
|
||||
"color",
|
||||
"steps",
|
||||
"maxSteps",
|
||||
"options",
|
||||
"permission",
|
||||
"disable",
|
||||
"tools",
|
||||
])
|
||||
const KNOWN_KEYS = new Set(["name", ...Object.keys(AgentSchema.schema.fields)])
|
||||
|
||||
const normalize = (agent: Schema.Schema.Type<typeof AgentSchema>): Schema.Schema.Type<typeof AgentSchema> => {
|
||||
const options: Record<string, unknown> = { ...agent.options }
|
||||
|
||||
+34
-10
@@ -1,7 +1,7 @@
|
||||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import path from "path"
|
||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
@@ -47,8 +47,11 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const root = yield* Effect.scope
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const vcs = location.vcs
|
||||
const current: { info: Info } = { info: { branch: {} } }
|
||||
const refreshLock = Semaphore.makeUnsafe(1)
|
||||
const scope = {
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
@@ -69,7 +72,12 @@ const layer = Layer.effect(
|
||||
set: (selection) => (draft.selection = selection),
|
||||
},
|
||||
}),
|
||||
finalize: () => refresh(),
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fork(refresh()))
|
||||
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
yield* exit
|
||||
}),
|
||||
})
|
||||
const selected = () => {
|
||||
const value = state.get()
|
||||
@@ -87,13 +95,23 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const refresh = Effect.fn("Vcs.refresh")(function* () {
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
|
||||
: { branch: {} }
|
||||
const changed = current.info.branch.current !== next.branch.current
|
||||
current.info = next
|
||||
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
const changed = yield* Effect.gen(function* () {
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
|
||||
: { branch: {} }
|
||||
const changed = current.info.branch.current !== next.branch.current
|
||||
current.info = next
|
||||
return changed
|
||||
}).pipe(refreshLock.withPermit)
|
||||
if (!changed) return
|
||||
// Legacy listeners can publish nested updates before streams and SSE receive
|
||||
// this event. Re-announce the latest branch if publication was overtaken.
|
||||
while (true) {
|
||||
const branch = current.info.branch.current
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
|
||||
if (branch === current.info.branch.current) return
|
||||
}
|
||||
})
|
||||
|
||||
if (vcs) {
|
||||
@@ -105,7 +123,13 @@ const layer = Layer.effect(
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.filter((event) => isBranchMetadata(event.data.file)),
|
||||
Stream.runForEach((event) =>
|
||||
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
|
||||
refresh().pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) => Effect.logWarning("vcs refresh failed", { file: event.data.file, cause }),
|
||||
),
|
||||
Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } }),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ const layer = Layer.effect(
|
||||
set: (selection) => (draft.selection = selection),
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
|
||||
|
||||
@@ -124,62 +124,58 @@ const layer = Layer.effect(
|
||||
return entries
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("WellKnown.refresh")(function* () {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const value = yield* kv.get(sourcesKey)
|
||||
const origins = Schema.is(Sources)(value) ? value : []
|
||||
if (!origins.length) return false
|
||||
const entries = yield* Effect.forEach(origins, loadEntry)
|
||||
const next = new Map(entries.map((entry) => [entry.origin, entry]))
|
||||
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
|
||||
if (!changed) return false
|
||||
yield* Ref.set(cache, next)
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
const refresh = Effect.fn("WellKnown.refresh")(
|
||||
function* () {
|
||||
const value = yield* kv.get(sourcesKey)
|
||||
const origins = Schema.is(Sources)(value) ? value : []
|
||||
if (!origins.length) return false
|
||||
const entries = yield* Effect.forEach(origins, loadEntry)
|
||||
const next = new Map(entries.map((entry) => [entry.origin, entry]))
|
||||
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
|
||||
if (!changed) return false
|
||||
yield* Ref.set(cache, next)
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
return true
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
entries: load,
|
||||
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
|
||||
refresh,
|
||||
add: Effect.fn("WellKnown.add")(function* (value) {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const origin = value.replace(/\/+$/, "")
|
||||
const entry = yield* loadEntry(origin)
|
||||
if (!entry.manifest.auth)
|
||||
return yield* Effect.fail(new Error(`No authentication method found at ${origin}`))
|
||||
const sources = yield* kv.get(sourcesKey)
|
||||
const origins = Schema.is(Sources)(sources) ? sources : []
|
||||
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
|
||||
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
return entry
|
||||
}),
|
||||
)
|
||||
}),
|
||||
remove: Effect.fn("WellKnown.remove")(function* (value) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const origin = value.replace(/\/+$/, "")
|
||||
const sources = yield* kv.get(sourcesKey)
|
||||
const origins = Schema.is(Sources)(sources) ? sources : []
|
||||
yield* kv.set(
|
||||
sourcesKey,
|
||||
origins.filter((item) => item !== origin),
|
||||
)
|
||||
yield* Ref.update(cache, (current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(origin)
|
||||
return next
|
||||
})
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
add: Effect.fn("WellKnown.add")(
|
||||
function* (value) {
|
||||
const origin = value.replace(/\/+$/, "")
|
||||
const entry = yield* loadEntry(origin)
|
||||
if (!entry.manifest.auth) return yield* Effect.fail(new Error(`No authentication method found at ${origin}`))
|
||||
const sources = yield* kv.get(sourcesKey)
|
||||
const origins = Schema.is(Sources)(sources) ? sources : []
|
||||
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
|
||||
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
return entry
|
||||
},
|
||||
(effect, _value) => lock.withPermit(effect),
|
||||
),
|
||||
remove: Effect.fn("WellKnown.remove")(
|
||||
function* (value) {
|
||||
const origin = value.replace(/\/+$/, "")
|
||||
const sources = yield* kv.get(sourcesKey)
|
||||
const origins = Schema.is(Sources)(sources) ? sources : []
|
||||
yield* kv.set(
|
||||
sourcesKey,
|
||||
origins.filter((item) => item !== origin),
|
||||
)
|
||||
yield* Ref.update(cache, (current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(origin)
|
||||
return next
|
||||
})
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
},
|
||||
(effect, _value) => lock.withPermit(effect),
|
||||
),
|
||||
resolve: Effect.fn("WellKnown.resolveEntry")((entry, variables) =>
|
||||
resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -30,6 +31,57 @@ const catalogLayer = AppNodeBuilder.build(
|
||||
const it = testEffect(catalogLayer)
|
||||
|
||||
describe("Catalog", () => {
|
||||
it.effect("reads available and default models inside a batch before publishing", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
const observed: string[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Catalog.Event.Updated.type
|
||||
? catalog.model.default().pipe(
|
||||
Effect.map((model) => {
|
||||
observed.push(model?.id ?? "none")
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const providerID = Provider.ID.make("test")
|
||||
const old = Model.ID.make("old")
|
||||
const newest = Model.ID.make("new")
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, () => {})
|
||||
draft.model.update(providerID, old, (model) => {
|
||||
model.time.released = 1000
|
||||
})
|
||||
draft.model.update(providerID, newest, (model) => {
|
||||
model.time.released = 2000
|
||||
})
|
||||
draft.model.default.set(providerID, old)
|
||||
})
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest, old])
|
||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||
|
||||
const overlay = yield* catalog.transform((draft) =>
|
||||
draft.model.update(providerID, old, (model) => {
|
||||
model.enabled = false
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest])
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
yield* overlay.dispose
|
||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([old])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -294,6 +346,7 @@ describe("Catalog", () => {
|
||||
|
||||
configured = false
|
||||
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -34,6 +35,30 @@ test("rejects named agent color tokens", () => {
|
||||
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
|
||||
})
|
||||
|
||||
test("keeps schema fields and name out of legacy agent options", () => {
|
||||
const agent = Schema.decodeUnknownSync(ConfigAgentV1.Info)({
|
||||
name: "reviewer",
|
||||
model: "test/model",
|
||||
variant: "high",
|
||||
temperature: 0.5,
|
||||
top_p: 0.9,
|
||||
prompt: "Review carefully.",
|
||||
tools: { edit: false },
|
||||
disable: false,
|
||||
description: "Reviews changes",
|
||||
mode: "subagent",
|
||||
hidden: true,
|
||||
options: { existing: true },
|
||||
color: "#112233",
|
||||
steps: 10,
|
||||
maxSteps: 20,
|
||||
permission: { read: "allow" },
|
||||
custom: "preserved",
|
||||
})
|
||||
|
||||
expect(agent.options).toEqual({ existing: true, custom: "preserved" })
|
||||
})
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("matches POSIX paths against home-relative permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -351,6 +376,7 @@ Review carefully.`,
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "agents", "native.md"),
|
||||
`---
|
||||
variant: high
|
||||
request:
|
||||
headers:
|
||||
x-agent: native
|
||||
|
||||
@@ -362,6 +362,20 @@ describe("ConfigNormalize", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes MCP timeout fields in schema order with per-leaf recovery", () => {
|
||||
const result = normalized({ mcp: { timeout: { execution: 3000, startup: "invalid", catalog: 2000 } } })
|
||||
|
||||
expect(result.encoded.mcp).toEqual({ timeout: { catalog: 2000, execution: 3000 } })
|
||||
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
|
||||
["invalid", ["mcp", "timeout", "startup"]],
|
||||
])
|
||||
expect(normalized({ mcp: { timeout: {} } }).encoded.mcp).toBeUndefined()
|
||||
|
||||
const unknown = normalized({ mcp: { timeout: { unknown: 1000 } } })
|
||||
expect(unknown.encoded.mcp).toBeUndefined()
|
||||
expect(unknown.diagnostics.map((item) => [item.kind, item.path])).toEqual([["invalid", ["mcp", "timeout"]]])
|
||||
})
|
||||
|
||||
test("merges bounded compaction leaves and omits unsupported leaves", () => {
|
||||
const result = normalized({
|
||||
compaction: {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
|
||||
|
||||
describe("LocationWatcherPolicy", () => {
|
||||
it.effect("reads batched registrations and disposals without notifying observers", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* policy.transform((draft) => draft.add(["base"]))
|
||||
const overlay = yield* policy.transform((draft) => draft.add(["overlay"]))
|
||||
const snapshot = policy.current()
|
||||
expect(snapshot).toEqual(["base", "overlay"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* overlay.dispose
|
||||
expect(policy.current()).toEqual(["base"])
|
||||
expect(snapshot).toEqual(["base", "overlay"])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["base"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
let ignore = ["first"]
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
yield* policy.transform((draft) => draft.add(ignore))
|
||||
const snapshot = policy.current()
|
||||
observed.length = 0
|
||||
|
||||
ignore = ["second"]
|
||||
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual(["second"])
|
||||
expect(snapshot).toEqual(["first"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual([["second"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const observed: string[][] = []
|
||||
let reentered = false
|
||||
yield* policy.observe(() =>
|
||||
Effect.gen(function* () {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
yield* policy.transform((draft) => draft.add(["inner"])).pipe(Scope.provide(scope))
|
||||
}),
|
||||
)
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
yield* policy.transform((draft) => draft.add(["outer"]))
|
||||
|
||||
expect(policy.current()).toEqual(["outer", "inner"])
|
||||
expect(observed).toEqual([
|
||||
["outer", "inner"],
|
||||
["outer", "inner"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows an observer to await a reload and keeps later observers current", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
let ignore = ["first"]
|
||||
let reentered = false
|
||||
yield* policy.observe(() =>
|
||||
Effect.gen(function* () {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
ignore = ["second"]
|
||||
yield* policy.reload()
|
||||
}),
|
||||
)
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
const writer = yield* policy
|
||||
.transform((draft) => draft.add(ignore))
|
||||
.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual(["second"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(writer)
|
||||
expect(observed).toEqual([["second"], ["second"]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -14,6 +14,10 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
|
||||
} satisfies Location.Interface
|
||||
}
|
||||
|
||||
export function locationLayer(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||
return Layer.succeed(Location.Service, Location.Service.of(location(ref, input)))
|
||||
}
|
||||
|
||||
export const tempLocationLayer = Layer.unwrap(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -21,7 +25,7 @@ export const tempLocationLayer = Layer.unwrap(
|
||||
).pipe(
|
||||
Effect.map((tmp) => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
|
||||
return locationLayer(ref)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node])))
|
||||
|
||||
describe("Integration replay", () => {
|
||||
it.effect("fails and closes an OAuth attempt when fresh implementation replay throws", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("replay-test")
|
||||
const methodID = Integration.MethodID.make("code")
|
||||
const source = { fail: false, closed: false }
|
||||
const failure = new Error("integration transform replay failed")
|
||||
yield* integrations.transform((editor) => {
|
||||
if (source.fail) throw failure
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Fixture" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (source.closed = true))).pipe(
|
||||
Effect.as({
|
||||
mode: "code" as const,
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Enter the fixture code",
|
||||
callback: () =>
|
||||
Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "dummy-access",
|
||||
refresh: "dummy-refresh",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Fixture" })
|
||||
source.fail = true
|
||||
const reload = yield* integrations.reload().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
source.fail = false
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
}),
|
||||
)
|
||||
|
||||
const exit = yield* integrations.oauth
|
||||
.complete({ integrationID, attemptID: attempt.attemptID, code: "dummy-code" })
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(exit).toMatchObject(Exit.die(failure))
|
||||
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
|
||||
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
|
||||
status: "failed",
|
||||
message: failure.message,
|
||||
time: attempt.time,
|
||||
})
|
||||
expect(source.closed).toBe(true)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
|
||||
@@ -262,6 +263,102 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves stored OAuth with refresh registrations made inside a batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const method = Integration.OAuthMethod.make({
|
||||
id: Integration.MethodID.make("browser"),
|
||||
type: "oauth",
|
||||
label: "Browser",
|
||||
})
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: method.id,
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({
|
||||
...expired,
|
||||
access: "fresh",
|
||||
refresh: "fresh-refresh",
|
||||
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
|
||||
})
|
||||
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
|
||||
const connection = { type: "credential" as const, id: stored.id, label: stored.label }
|
||||
const calls: string[] = []
|
||||
const implementation = {
|
||||
integrationID,
|
||||
method,
|
||||
authorize: () => Effect.die("unexpected authorization"),
|
||||
refresh: (value: Credential.OAuth) =>
|
||||
Effect.sync(() => {
|
||||
expect(value).toEqual(expired)
|
||||
calls.push("original")
|
||||
return fresh
|
||||
}),
|
||||
}
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* integrations.transform((editor) => editor.method.update(implementation))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(calls).toEqual(["original"])
|
||||
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect(calls).toEqual(["original"])
|
||||
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
const overridden = Credential.OAuth.make({ ...fresh, access: "override" })
|
||||
const override = yield* integrations.transform((editor) =>
|
||||
editor.method.update({
|
||||
...implementation,
|
||||
refresh: (value) =>
|
||||
Effect.sync(() => {
|
||||
expect(value).toEqual(expired)
|
||||
calls.push("override")
|
||||
return overridden
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(overridden)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(overridden)
|
||||
|
||||
yield* override.dispose
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect(calls).toEqual(["original", "override", "original"])
|
||||
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
const removal = yield* integrations.transform((editor) => editor.method.remove(integrationID, method))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
expect(calls).toEqual(["original", "override", "original"])
|
||||
|
||||
yield* removal.dispose
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
yield* integrations.transform((editor) => editor.method.update({ ...implementation, refresh: undefined }))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
expect(calls).toEqual(["original", "override", "original", "original"])
|
||||
|
||||
const failure = new Error("refresh failed")
|
||||
yield* integrations.transform((editor) =>
|
||||
editor.method.update({ ...implementation, refresh: () => Effect.fail(failure) }),
|
||||
)
|
||||
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
|
||||
new Integration.AuthorizationError({ cause: failure }),
|
||||
)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes code OAuth once and stores the credential", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -33,16 +33,31 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import {
|
||||
Context,
|
||||
Deferred,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
PubSub,
|
||||
Ref,
|
||||
Schedule,
|
||||
Schema,
|
||||
Scope,
|
||||
Sink,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { location, locationLayer } from "./fixture/location"
|
||||
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
@@ -67,7 +82,7 @@ function resourceServer(
|
||||
listChanged?: boolean
|
||||
emptyElicitation?: boolean
|
||||
urlElicitation?: boolean
|
||||
respond?: (request: Request) => Response | undefined
|
||||
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
|
||||
} = {},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
@@ -176,7 +191,7 @@ function resourceServer(
|
||||
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
|
||||
state.initializations += 1
|
||||
}
|
||||
return input.respond?.(request) ?? transport.handleRequest(request)
|
||||
return (await input.respond?.(request)) ?? transport.handleRequest(request)
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1411,6 +1426,126 @@ test("reconciles only changed MCP server config", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("serializes MCP config restoration behind an in-flight replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const accepted = yield* Deferred.make<void>()
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) =>
|
||||
request.method !== "POST"
|
||||
? undefined
|
||||
: Effect.runPromise(
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
|
||||
),
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
const replacing = yield* service
|
||||
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const restoring = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(accepted)
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(replacing)
|
||||
yield* Fiber.join(restoring)
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
expect(server.state.initializations).toBe(1)
|
||||
}).pipe(
|
||||
Effect.ensuring(Deferred.succeed(release, undefined)),
|
||||
Effect.provide(
|
||||
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const shutdownIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
|
||||
[
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
[Environment.node, hostEnvironmentLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
;["active", "queued"].forEach((phase) =>
|
||||
shutdownIt.effect(`discards ${phase} MCP notifications after its layer closes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
|
||||
const service = Context.get(context, Mcp.Service)
|
||||
const observed: string[] = []
|
||||
let block = false
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== McpEvent.StatusChanged.type) return
|
||||
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
|
||||
if (!block) return
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const source = { url: "https://example.com/initial", added: false }
|
||||
yield* service
|
||||
.transform((draft) => {
|
||||
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
|
||||
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
|
||||
block = true
|
||||
source.url = "https://example.com/first"
|
||||
source.added = phase === "active"
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
source.url = "https://example.com/second"
|
||||
source.added = true
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
|
||||
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(first.pollUnsafe()).toBeDefined()
|
||||
expect(second.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
yield* Fiber.join(shutdown)
|
||||
observed.length = 0
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(observed).toEqual([])
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
@@ -103,6 +105,64 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({
|
||||
...expired,
|
||||
access: "fresh",
|
||||
refresh: "fresh-refresh",
|
||||
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
|
||||
})
|
||||
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
|
||||
const resolved: (Credential.Value | undefined)[] = []
|
||||
const refreshed: Credential.OAuth[] = []
|
||||
|
||||
yield* plugins.activate([
|
||||
versioned(
|
||||
EffectPlugin.define({
|
||||
id: "oauth-refresh",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.integration.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () => Effect.die("unexpected authorization"),
|
||||
refresh: (value) =>
|
||||
Effect.sync(() => {
|
||||
refreshed.push(value)
|
||||
return fresh
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
if (!connection) return yield* Effect.die("stored connection missing")
|
||||
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
|
||||
}),
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
expect(resolved).toEqual([fresh])
|
||||
expect(refreshed).toEqual([expired])
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -110,6 +112,59 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "expired",
|
||||
refresh: "dummy",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({ ...expired, access: "fresh", expires: Number.MAX_SAFE_INTEGER })
|
||||
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
|
||||
const resolved: string[] = []
|
||||
const refreshed: string[] = []
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-oauth-refresh",
|
||||
setup: async (ctx) => {
|
||||
await ctx.integration.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: async () => {
|
||||
throw new Error("unexpected authorization")
|
||||
},
|
||||
refresh: async (value) => {
|
||||
refreshed.push(value.access)
|
||||
return fresh
|
||||
},
|
||||
}),
|
||||
)
|
||||
const connection = await ctx.integration.connection.active(integrationID)
|
||||
if (!connection) throw new Error("stored connection missing")
|
||||
const value = await ctx.integration.connection.resolve(connection)
|
||||
resolved.push(value?.type === "oauth" ? value.access : "missing")
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, version: "1" }])
|
||||
|
||||
expect(resolved).toEqual(["fresh"])
|
||||
expect(refreshed).toEqual(["expired"])
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("promise-oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes the host location including workspace and project metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -1,23 +1,147 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { it } from "./lib/effect"
|
||||
import { it, testEffect } from "./lib/effect"
|
||||
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: () => Effect.die("unexpected Git materialization"),
|
||||
})
|
||||
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
|
||||
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
|
||||
[RepositoryCache.node, cache],
|
||||
])
|
||||
const referenceIt = testEffect(referenceLayer)
|
||||
|
||||
describe("Reference", () => {
|
||||
it.effect("registers normalized sources for the owning scope", () =>
|
||||
it.effect("prepares batched references before cache work or update events", () => {
|
||||
const operations: RepositoryCache.EnsureInput[] = []
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: (input) =>
|
||||
Effect.sync(() => {
|
||||
operations.push(input)
|
||||
return {
|
||||
repository: input.reference.label,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath: Repository.cachePath(Global.Path.repos, input.reference, input.branch),
|
||||
status: "cached",
|
||||
} satisfies RepositoryCache.Result
|
||||
}),
|
||||
})
|
||||
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
|
||||
[RepositoryCache.node, cache],
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const bus = yield* Bus.Service
|
||||
const observed: string[][] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Reference.Event.Updated.type
|
||||
? references.list().pipe(
|
||||
Effect.map((infos) => {
|
||||
observed.push(infos.map((info) => info.name))
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* references.transform((draft) => {
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }))
|
||||
draft.add(
|
||||
"sdk",
|
||||
Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: "owner/repo",
|
||||
branch: "feature/docs",
|
||||
description: "SDK documentation",
|
||||
hidden: true,
|
||||
}),
|
||||
)
|
||||
draft.add("invalid", Reference.GitSource.make({ type: "git", repository: "invalid" }))
|
||||
draft.add(
|
||||
"invalid-branch",
|
||||
Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "../escape" }),
|
||||
)
|
||||
draft.add("file", Reference.GitSource.make({ type: "git", repository: "file:///docs" }))
|
||||
})
|
||||
const infos = yield* references.list()
|
||||
expect(infos.map((info) => info.name)).toEqual(["docs", "sdk"])
|
||||
expect(infos[1]).toMatchObject({
|
||||
path: Repository.cachePath(Global.Path.repos, Repository.parseRemote("owner/repo"), "feature/docs"),
|
||||
description: "SDK documentation",
|
||||
hidden: true,
|
||||
})
|
||||
expect(operations).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["docs", "sdk"]])
|
||||
yield* Effect.yieldNow
|
||||
expect(
|
||||
operations.map((input) => ({
|
||||
repository: input.reference.label,
|
||||
branch: input.branch,
|
||||
refresh: input.refresh,
|
||||
})),
|
||||
).toEqual([{ repository: "owner/repo", branch: "feature/docs", refresh: true }])
|
||||
}).pipe(Effect.scoped, Effect.provide(referenceLayer))
|
||||
})
|
||||
|
||||
referenceIt.effect("lets update listeners replace references and refetch the latest projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const scope = yield* Scope.make()
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const observed: string[][] = []
|
||||
let reentered = false
|
||||
const first = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== Reference.Event.Updated.type || reentered) return
|
||||
reentered = true
|
||||
yield* references
|
||||
.transform((draft) =>
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/new") })),
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
}),
|
||||
)
|
||||
const second = yield* bus.listen((event) =>
|
||||
event.type === Reference.Event.Updated.type
|
||||
? references.list().pipe(
|
||||
Effect.map((infos) => {
|
||||
observed.push(infos.map((info) => info.path))
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => first.pipe(Effect.andThen(second)))
|
||||
|
||||
yield* references.transform((draft) =>
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/old") })),
|
||||
)
|
||||
|
||||
expect((yield* references.list()).map((info) => info.path)).toEqual([AbsolutePath.make("/new")])
|
||||
expect(observed).toEqual([["/new"], ["/new"]])
|
||||
}),
|
||||
)
|
||||
|
||||
referenceIt.effect("registers normalized sources for the owning scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const parent = yield* Effect.scope
|
||||
const scope = yield* Scope.fork(parent)
|
||||
const path = AbsolutePath.make("/docs")
|
||||
const source = Reference.LocalSource.make({
|
||||
type: "local",
|
||||
@@ -33,10 +157,10 @@ describe("Reference", () => {
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* references.list()).toEqual([])
|
||||
}).pipe(Effect.provide(referenceLayer)),
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives Git paths without exposing cache operations", () =>
|
||||
referenceIt.effect("derives Git paths without exposing cache operations", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const repository = Repository.parseRemote("owner/repo")
|
||||
@@ -50,10 +174,10 @@ describe("Reference", () => {
|
||||
source,
|
||||
}),
|
||||
])
|
||||
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves configured Git descriptions", () =>
|
||||
referenceIt.effect("preserves configured Git descriptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const repository = Repository.parseRemote("owner/repo")
|
||||
@@ -72,6 +196,6 @@ describe("Reference", () => {
|
||||
source,
|
||||
}),
|
||||
])
|
||||
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -552,6 +552,7 @@ const setup = Effect.gen(function* () {
|
||||
admit,
|
||||
resume,
|
||||
context: session.context(sessionID),
|
||||
hooks,
|
||||
messages: session.messages({ sessionID }),
|
||||
inbox: session.inbox(sessionID),
|
||||
runPrompt: Effect.fnUntraced(function* (text: string) {
|
||||
@@ -4356,9 +4357,14 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.admit("Retry transport")
|
||||
yield* s.llm.push(Stream.fail(providerUnavailable()))
|
||||
yield* s.llm.push(TestLLM.text("Recovered", "retry-success"))
|
||||
const scheduled = yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* s.llm.wait(1)
|
||||
yield* Fiber.join(scheduled)
|
||||
yield* TestClock.adjust("1599 millis")
|
||||
expect(s.requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("801 millis")
|
||||
@@ -4376,6 +4382,81 @@ describe("SessionRunnerLLM", () => {
|
||||
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
})
|
||||
|
||||
scenario("allows session retry hooks to veto a proposed retry", function* (s) {
|
||||
const failure = providerUnavailable()
|
||||
let observed: PluginHooks.Domains["session"]["retry"] | undefined
|
||||
yield* s.hooks.register("session", "retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
observed = event
|
||||
event.decision = { retry: false }
|
||||
}),
|
||||
)
|
||||
yield* s.llm.push(Stream.fail(failure))
|
||||
|
||||
expect(yield* s.runPrompt("Do not retry transport").pipe(Effect.flip)).toBe(failure)
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(observed).toMatchObject({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: "fake", id: "fake-model" },
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
attempt: 2,
|
||||
decision: { retry: false },
|
||||
})
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
})
|
||||
|
||||
scenario("allows session retry hooks to retry a terminal provider failure", function* (s) {
|
||||
yield* s.hooks.register("session", "retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.decision).toEqual({ retry: false })
|
||||
event.decision = { retry: true, delay: 0 }
|
||||
}),
|
||||
)
|
||||
yield* s.admit("Retry invalid request")
|
||||
yield* s.llm.push(Stream.fail(invalidRequest()), TestLLM.text("Recovered", "forced-retry-success"))
|
||||
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(yield* s.context).toMatchObject([
|
||||
Expected.user("Retry invalid request"),
|
||||
Expected.assistant({ finish: "stop" }, [Expected.text("Recovered")]),
|
||||
])
|
||||
})
|
||||
|
||||
scenario("uses the final session retry hook delay", function* (s) {
|
||||
yield* s.hooks.register("session", "retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.decision = { retry: true, delay: 10_000 }
|
||||
}),
|
||||
)
|
||||
yield* s.hooks.register("session", "retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.decision).toEqual({ retry: true, delay: 10_000 })
|
||||
event.decision = { retry: true, delay: 5_000 }
|
||||
}),
|
||||
)
|
||||
yield* s.admit("Use custom retry delay")
|
||||
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Recovered", "hook-delay-success"))
|
||||
const scheduled = yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* Fiber.join(scheduled)
|
||||
yield* TestClock.adjust("4999 millis")
|
||||
expect(s.requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
const assistant = requireAssistant(yield* s.context)
|
||||
expect(assistant.retry).toBeUndefined()
|
||||
})
|
||||
|
||||
scenario("does not start another physical attempt after interruption during retry backoff", function* (s) {
|
||||
yield* s.admit("Interrupt retry backoff")
|
||||
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry"))
|
||||
|
||||
@@ -105,6 +105,7 @@ for (const fixture of [
|
||||
agent: Agent.defaultID,
|
||||
model,
|
||||
prepared: {
|
||||
retry: () => Effect.void,
|
||||
request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }),
|
||||
options: {},
|
||||
executeTool: () =>
|
||||
@@ -113,6 +114,8 @@ for (const fixture of [
|
||||
return { content: "Completed tool" }
|
||||
}),
|
||||
},
|
||||
retry: (_cause, _error, retry) =>
|
||||
Effect.succeed(retry ? { retry: true, attempt: 2, delay: 0 } : { retry: false }),
|
||||
recoverContinuation: true,
|
||||
recoverOverflow: Effect.succeed(false),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scheduler, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
function valuesState(
|
||||
hooks: Pick<State.Options<{ values: string[] }, { add: (item: string) => void }>, "prepare" | "notify"> = {},
|
||||
) {
|
||||
return State.create({
|
||||
initial: () => ({ values: new Array<string>() }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
...hooks,
|
||||
})
|
||||
}
|
||||
|
||||
describe("State", () => {
|
||||
it.effect("commits a transform atomically when its updater is interrupted", () =>
|
||||
@@ -12,17 +20,13 @@ describe("State", () => {
|
||||
const rebuilding = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
let block = true
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
|
||||
finalize: () =>
|
||||
const state = valuesState({
|
||||
notify: () =>
|
||||
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
const fiber = yield* state
|
||||
.transform((editor) => {
|
||||
editor.add("registered")
|
||||
})
|
||||
.transform((editor) => editor.add("registered"))
|
||||
.pipe(Scope.provide(scope), Effect.forkChild)
|
||||
yield* Deferred.await(rebuilding)
|
||||
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
|
||||
@@ -36,20 +40,16 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("commits rebuilt state before finalize runs", () =>
|
||||
it.effect("makes rebuilt state visible before notifying", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[][] = []
|
||||
const state: State.Interface<{ values: string[] }, { add: (item: string) => void }> = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
})
|
||||
|
||||
yield* state.transform((draft) => {
|
||||
draft.add("value")
|
||||
})
|
||||
yield* state.transform((draft) => draft.add("value"))
|
||||
|
||||
// Update events publish from finalize, so consumers reading on the event
|
||||
// Update events publish from notify, so consumers reading on the event
|
||||
// must observe the rebuilt state, not the previous one.
|
||||
expect(observed).toEqual([["value"]])
|
||||
}),
|
||||
@@ -58,14 +58,9 @@ describe("State", () => {
|
||||
it.effect("runs transforms during every reload", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
})
|
||||
const state = valuesState()
|
||||
|
||||
yield* state.transform((editor) => {
|
||||
editor.add(value)
|
||||
})
|
||||
yield* state.transform((editor) => editor.add(value))
|
||||
expect(state.get().values).toEqual(["first"])
|
||||
|
||||
value = "second"
|
||||
@@ -76,18 +71,327 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads registrations and disposals inside a batch without publishing", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[][] = []
|
||||
let replays = 0
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state
|
||||
.transform((draft) => {
|
||||
replays++
|
||||
draft.add("value")
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
|
||||
const snapshot = state.get()
|
||||
expect(snapshot.values).toEqual(["value"])
|
||||
expect(state.get()).toBe(snapshot)
|
||||
expect(replays).toBe(1)
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(state.get().values).toEqual([])
|
||||
expect(snapshot.values).toEqual(["value"])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([[]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads a requested reload without waiting for its notification debounce", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
let replays = 0
|
||||
const observed: string[][] = []
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
replays++
|
||||
draft.add(value)
|
||||
})
|
||||
const snapshot = state.get()
|
||||
observed.length = 0
|
||||
|
||||
value = "second"
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("50 millis")
|
||||
|
||||
expect(state.get().values).toEqual(["second"])
|
||||
expect(snapshot.values).toEqual(["first"])
|
||||
expect(replays).toBe(2)
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("450 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual([["second"]])
|
||||
expect(replays).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can await reload inside a batch while deferring its notification", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
let notifications = 0
|
||||
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => draft.add(value))
|
||||
expect(state.get().values).toEqual(["first"])
|
||||
value = "second"
|
||||
yield* state.reload()
|
||||
expect(state.get().values).toEqual(["second"])
|
||||
expect(notifications).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(notifications).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares derived data during reads without running observers", () =>
|
||||
Effect.gen(function* () {
|
||||
let notifications = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[], joined: "" }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
prepare: (data) => {
|
||||
data.joined = data.values.join(",")
|
||||
},
|
||||
notify: () => Effect.sync(() => notifications++),
|
||||
})
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => draft.add("first"))
|
||||
yield* state.transform((draft) => draft.add("second"))
|
||||
expect(state.get().joined).toBe("first,second")
|
||||
expect(notifications).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(notifications).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps replay failures observable without replacing the previous snapshot", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
const state = valuesState({
|
||||
prepare: () => {
|
||||
if (fail) throw new Error("preparation failed")
|
||||
},
|
||||
})
|
||||
yield* state.transform((draft) => draft.add("first"))
|
||||
const snapshot = state.get()
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => draft.add("second"))
|
||||
fail = true
|
||||
expect(() => state.get()).toThrow("preparation failed")
|
||||
expect(() => state.get()).toThrow("preparation failed")
|
||||
expect(snapshot.values).toEqual(["first"])
|
||||
fail = false
|
||||
expect(state.get().values).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows an observer to await a registration on the same state", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
let added = false
|
||||
const observed: string[][] = []
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push([...state.get().values])
|
||||
if (added) return
|
||||
added = true
|
||||
yield* state.transform((draft) => draft.add("second")).pipe(Scope.provide(scope))
|
||||
}),
|
||||
})
|
||||
|
||||
yield* state.transform((draft) => draft.add("first"))
|
||||
expect(observed).toEqual([["first"], ["first", "second"]])
|
||||
expect(state.get().values).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows a debounced observer to await another reload", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
let reloadAgain = false
|
||||
const observed: string[][] = []
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push([...state.get().values])
|
||||
if (!reloadAgain) return
|
||||
reloadAgain = false
|
||||
value = "third"
|
||||
yield* state.reload()
|
||||
}),
|
||||
})
|
||||
yield* state.transform((draft) => draft.add(value))
|
||||
observed.length = 0
|
||||
|
||||
value = "second"
|
||||
reloadAgain = true
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Fiber.join(reload)
|
||||
|
||||
expect(observed).toEqual([["second"], ["third"]])
|
||||
expect(state.get().values).toEqual(["third"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps reload waiters associated with their own notification results", () =>
|
||||
Effect.gen(function* () {
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
let value = "first"
|
||||
let block = false
|
||||
const observed: string[][] = []
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push([...state.get().values])
|
||||
if (!block) return
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return yield* Effect.die(new Error("first notification failed"))
|
||||
}),
|
||||
})
|
||||
yield* state.transform((draft) => draft.add(value))
|
||||
// Release the detached worker before the earlier registration finalizer runs.
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
|
||||
observed.length = 0
|
||||
|
||||
value = "second"
|
||||
block = true
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
|
||||
value = "third"
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(state.get().values).toEqual(["third"])
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(second)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(observed).toEqual([["second"], ["third"]])
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const exit = yield* Fiber.await(first)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("first notification failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps coalesced reload callers independent of cancellation and shares their notification failure", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
let notifications = 0
|
||||
const failure = new Error("notification failed")
|
||||
const state = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
notify: () =>
|
||||
Effect.sync(() => {
|
||||
notifications++
|
||||
if (fail) throw failure
|
||||
}),
|
||||
})
|
||||
yield* state.transform(() => {})
|
||||
notifications = 0
|
||||
fail = true
|
||||
|
||||
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Fiber.interrupt(cancelled)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
const exits = yield* Fiber.awaitAll([first, second])
|
||||
fail = false
|
||||
|
||||
expect(Exit.hasInterrupts(yield* Fiber.await(cancelled))).toBe(true)
|
||||
expect(exits.map((exit) => Exit.isFailure(exit) && Cause.squash(exit.cause))).toEqual([failure, failure])
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(recovered)
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues publishing when a reload caller is cancelled while scheduling its worker", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
let notifications = 0
|
||||
let interrupted = false
|
||||
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
yield* state.transform((draft) => draft.add(value))
|
||||
notifications = 0
|
||||
|
||||
value = "second"
|
||||
const cancelled = yield* Effect.withFiber((fiber) => {
|
||||
const base = new Scheduler.MixedScheduler("sync")
|
||||
const scheduler: Scheduler.Scheduler = {
|
||||
executionMode: base.executionMode,
|
||||
// Keep the first scheduled task at the detached worker handoff.
|
||||
shouldYield: () => false,
|
||||
makeDispatcher: () => {
|
||||
const dispatcher = base.makeDispatcher()
|
||||
return {
|
||||
scheduleTask: (task, priority) => {
|
||||
if (!interrupted) {
|
||||
interrupted = true
|
||||
fiber.interruptUnsafe()
|
||||
}
|
||||
dispatcher.scheduleTask(task, priority)
|
||||
},
|
||||
flush: () => dispatcher.flush(),
|
||||
}
|
||||
},
|
||||
}
|
||||
return state.reload().pipe(Effect.provideService(Scheduler.Scheduler, scheduler))
|
||||
}).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const exit = yield* Fiber.await(cancelled)
|
||||
expect(interrupted).toBe(true)
|
||||
expect(Exit.hasInterrupts(exit)).toBe(true)
|
||||
expect(state.get().values).toEqual(["second"])
|
||||
yield* TestClock.adjust("500 millis")
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
value = "third"
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(state.get().values).toEqual(["third"])
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disposes a transform once and rebuilds remaining state", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
})
|
||||
yield* state.transform((editor) => {
|
||||
editor.add("first")
|
||||
})
|
||||
const registration = yield* state.transform((editor) => {
|
||||
editor.add("second")
|
||||
})
|
||||
const state = valuesState()
|
||||
yield* state.transform((editor) => editor.add("first"))
|
||||
const registration = yield* state.transform((editor) => editor.add("second"))
|
||||
expect(state.get().values).toEqual(["first", "second"])
|
||||
|
||||
yield* registration.dispose
|
||||
@@ -98,49 +402,137 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches automatic rebuilds", () =>
|
||||
it.effect("batches notifications", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
const first = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
const second = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
let notifications = 0
|
||||
const first = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
const second = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* first.transform((draft) => {
|
||||
draft.add("first")
|
||||
})
|
||||
yield* first.transform((draft) => {
|
||||
draft.add("second")
|
||||
})
|
||||
yield* second.transform((draft) => {
|
||||
draft.add("third")
|
||||
})
|
||||
expect(finalized).toBe(0)
|
||||
yield* first.transform((draft) => draft.add("first"))
|
||||
yield* first.transform((draft) => draft.add("second"))
|
||||
yield* second.transform((draft) => draft.add("third"))
|
||||
expect(notifications).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(first.get().values).toEqual(["first", "second"])
|
||||
expect(second.get().values).toEqual(["third"])
|
||||
expect(finalized).toBe(2)
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes a batched observer's owning scope without losing the body's failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const registrations = yield* Scope.make()
|
||||
const owner = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(Scope.close(owner, Exit.void)),
|
||||
Effect.andThen(State.batch(Scope.close(registrations, Exit.void), { flush: false })),
|
||||
),
|
||||
)
|
||||
const state = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
notify: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
})
|
||||
const writer = yield* State.batch(
|
||||
state.transform(() => {}).pipe(Scope.provide(registrations), Effect.andThen(Effect.fail("batch body failed"))),
|
||||
).pipe(Effect.forkIn(owner, { startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
|
||||
const shutdown = yield* Scope.close(owner, Exit.void).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
const exit = yield* Fiber.await(writer)
|
||||
expect(Exit.hasInterrupts(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("batch body failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets batch observers read the other states' accepted changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[][] = []
|
||||
const first = valuesState({
|
||||
notify: () => Effect.sync(() => observed.push([...second.get().values])),
|
||||
})
|
||||
const second = valuesState()
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* first.transform((draft) => draft.add("first"))
|
||||
yield* second.transform((draft) => draft.add("second"))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["second"]])
|
||||
}),
|
||||
)
|
||||
;["replay", "notification"].forEach((failure) =>
|
||||
it.effect(`notifies the other states when a batch ${failure} fails`, () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = true
|
||||
const observed: string[] = []
|
||||
const first = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
notify: () => Effect.sync(() => observed.push("first")),
|
||||
})
|
||||
const failing = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
prepare: () => {
|
||||
if (fail && failure === "replay") throw new Error("replay failed")
|
||||
},
|
||||
notify: () =>
|
||||
fail ? Effect.die(new Error("notification failed")) : Effect.sync(() => observed.push("failing")),
|
||||
})
|
||||
const last = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
notify: () => Effect.sync(() => observed.push("last")),
|
||||
})
|
||||
|
||||
const exit = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* first.transform(() => {})
|
||||
yield* failing.transform(() => {})
|
||||
yield* last.transform(() => {})
|
||||
return yield* Effect.die(new Error("batch failed"))
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
fail = false
|
||||
|
||||
expect(observed).toEqual(["first", "last"])
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.pretty(exit.cause)).toContain("batch failed")
|
||||
expect(Cause.pretty(exit.cause)).toContain(`${failure} failed`)
|
||||
}
|
||||
|
||||
const reload = yield* failing.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual(["first", "last", "failing"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
let notifications = 0
|
||||
let prepared = 0
|
||||
let disposed = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
const state = valuesState({
|
||||
prepare: () => {
|
||||
prepared++
|
||||
},
|
||||
notify: () => Effect.sync(() => notifications++),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.addFinalizer(
|
||||
@@ -148,38 +540,44 @@ describe("State", () => {
|
||||
Effect.sync(() => disposed++),
|
||||
)
|
||||
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
|
||||
expect(finalized).toBe(1)
|
||||
const snapshot = state.get()
|
||||
expect(notifications).toBe(1)
|
||||
expect(prepared).toBe(1)
|
||||
|
||||
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("250 millis")
|
||||
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
|
||||
expect(disposed).toBe(1)
|
||||
expect(finalized).toBe(1)
|
||||
expect(notifications).toBe(1)
|
||||
expect(state.get()).toBe(snapshot)
|
||||
expect(prepared).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(pending)
|
||||
yield* registration.dispose
|
||||
yield* state.reload()
|
||||
expect(finalized).toBe(1)
|
||||
expect(notifications).toBe(1)
|
||||
expect(state.get()).toBe(snapshot)
|
||||
expect(prepared).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps teardown suppression separate from an enclosing live batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const finalized: string[] = []
|
||||
const notifications: string[] = []
|
||||
const closing = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
finalize: () => Effect.sync(() => finalized.push("closing")),
|
||||
notify: () => Effect.sync(() => notifications.push("closing")),
|
||||
})
|
||||
const live = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
finalize: () => Effect.sync(() => finalized.push("live")),
|
||||
notify: () => Effect.sync(() => notifications.push("live")),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
|
||||
finalized.length = 0
|
||||
notifications.length = 0
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
@@ -187,33 +585,27 @@ describe("State", () => {
|
||||
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
|
||||
}),
|
||||
)
|
||||
expect(finalized).toEqual(["live"])
|
||||
expect(notifications).toEqual(["live"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("debounces reload bursts", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
draft.add("value")
|
||||
})
|
||||
finalized = 0
|
||||
let notifications = 0
|
||||
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
yield* state.transform((draft) => draft.add("value"))
|
||||
notifications = 0
|
||||
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("250 millis")
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("499 millis")
|
||||
expect(finalized).toBe(0)
|
||||
expect(notifications).toBe(0)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
|
||||
expect(finalized).toBe(1)
|
||||
expect(notifications).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -311,7 +311,7 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
|
||||
it.effect("reads refreshed sources before notifications and keeps advertised snapshots", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
let source: Info[] = []
|
||||
@@ -321,24 +321,24 @@ describe("Tool", () => {
|
||||
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
|
||||
source = [tool]
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(first)
|
||||
const advertised = yield* service.snapshot()
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(first)
|
||||
|
||||
tool.execute = constant("second").execute
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(second)
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
|
||||
source = []
|
||||
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(removed)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
}),
|
||||
)
|
||||
@@ -370,7 +370,7 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
|
||||
it.effect("batches tool notifications with fresh snapshots and suppresses terminal teardown replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const runs: string[] = []
|
||||
@@ -386,7 +386,8 @@ describe("Tool", () => {
|
||||
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
|
||||
})
|
||||
expect(runs).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
expect(runs).toEqual(["base", "overlay"])
|
||||
}).pipe(Scope.provide(scope)),
|
||||
)
|
||||
|
||||
@@ -545,23 +546,32 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
|
||||
it.effect("compiles healthy tools before notifying invalid definition diagnostics", () => {
|
||||
const output: unknown[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
output.push(entry.message)
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
|
||||
draft.add({
|
||||
name: "phone_type",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
} as unknown as Info)
|
||||
draft.add({ ...make(), name: "codemode" })
|
||||
})
|
||||
const snapshot = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
|
||||
draft.add({
|
||||
name: "phone_type",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
} as unknown as Info)
|
||||
draft.add({ ...make(), name: "codemode" })
|
||||
})
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
|
||||
expect(output).toEqual([])
|
||||
return snapshot
|
||||
}),
|
||||
)
|
||||
|
||||
expect(output).toEqual([
|
||||
[
|
||||
@@ -573,9 +583,6 @@ describe("Tool", () => {
|
||||
},
|
||||
],
|
||||
])
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
|
||||
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
+371
-90
@@ -2,35 +2,40 @@ import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { VcsGitPlugin } from "@opencode-ai/core/plugin/vcs/git"
|
||||
import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { locationLayer } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { it, testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const Done = Bus.ephemeral({ type: "test.vcs.done", schema: {} })
|
||||
|
||||
const synthetic = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node]), [
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
]),
|
||||
)
|
||||
|
||||
const provide = (directory: string, input: { git?: boolean } = {}) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
),
|
||||
locationLayer(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
],
|
||||
]),
|
||||
@@ -84,40 +89,36 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
|
||||
}) satisfies VcsDefinition
|
||||
|
||||
describe("Vcs", () => {
|
||||
it.live("returns empty results outside version control", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.branches()).toEqual([])
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
synthetic.effect("returns empty results outside version control", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.branches()).toEqual([])
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves scoped providers and restores the fallback after disposal", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((draft) => {
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
synthetic.effect("serves scoped providers and restores the fallback after disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((draft) => {
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
expect(yield* vcs.diff("working")).toEqual([
|
||||
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
|
||||
])
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
expect(yield* vcs.diff("working")).toEqual([
|
||||
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
|
||||
])
|
||||
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("automatically selects a provider matching the resolved repository", () =>
|
||||
@@ -133,80 +134,360 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("passes location scope and bounded diff options to providers", () =>
|
||||
withTmp((directory) =>
|
||||
synthetic.effect("passes location scope and bounded diff options to providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: VcsDiffInput[] = []
|
||||
const vcs = yield* Vcs.Service
|
||||
const location = yield* Location.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
diff: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
yield* vcs.diff("branch", { context: 3 })
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
directory: location.directory,
|
||||
worktree: location.directory,
|
||||
canonical: location.directory,
|
||||
mode: "branch",
|
||||
context: 3,
|
||||
maxOutputBytes: 10_000_000,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
synthetic.effect("validates provider results and bounds oversized patches", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
|
||||
diff: () =>
|
||||
Effect.succeed([
|
||||
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
|
||||
]),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
const rows = yield* vcs.diff("working")
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
|
||||
expect(rows[0].additions).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
synthetic.effect("preserves provider interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
let interrupt = false
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
info: () =>
|
||||
interrupt ? Effect.interrupt : Effect.succeed({ branch: { current: "feature", default: "main" } }),
|
||||
status: () => Effect.never,
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
const fiber = yield* Effect.forkChild(vcs.status())
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
|
||||
|
||||
interrupt = true
|
||||
const reload = yield* vcs.reload().pipe(Effect.timeout("1 second"), Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
const reloaded = yield* Fiber.await(reload)
|
||||
expect(Exit.isFailure(reloaded) && Cause.hasInterruptsOnly(reloaded.cause)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps watching HEAD changes after a transform replay failure", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const observed: VcsDiffInput[] = []
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
const bus = yield* Bus.Service
|
||||
const replayed = yield* Deferred.make<void>()
|
||||
const faulty = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(faulty, Exit.void))
|
||||
let branch = "initial"
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(provider({ id: "git", info: () => Effect.sync(() => ({ branch: { current: branch } })) })),
|
||||
)
|
||||
const failure = new Error("fixture replay failed")
|
||||
let replays = 0
|
||||
const failed = yield* vcs
|
||||
.transform(() => {
|
||||
if (++replays === 2) Deferred.doneUnsafe(replayed, Exit.void)
|
||||
throw failure
|
||||
})
|
||||
.pipe(Scope.provide(faulty), Effect.exit)
|
||||
expect(Exit.isFailure(failed) && Cause.squash(failed.cause)).toBe(failure)
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
yield* Deferred.await(replayed).pipe(Effect.timeout("1 second"))
|
||||
yield* Effect.yieldNow
|
||||
const status = yield* vcs.status().pipe(Effect.exit)
|
||||
expect(Exit.isFailure(status) && Cause.squash(status.cause)).toBe(failure)
|
||||
expect((yield* vcs.info()).branch.current).toBe("initial")
|
||||
|
||||
branch = "recovered"
|
||||
yield* Scope.close(faulty, Exit.void)
|
||||
expect((yield* vcs.info()).branch.current).toBe("recovered")
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.runHead, Effect.timeout("1 second"), Effect.forkScoped({ startImmediately: true }))
|
||||
branch = "after-recovery"
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
const event = yield* Fiber.join(updated)
|
||||
expect((yield* vcs.info()).branch.current).toBe("after-recovery")
|
||||
expect(Option.getOrUndefined(event)).toMatchObject({ data: { branch: "after-recovery" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("stops in-flight and queued reloads when its layer closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(
|
||||
LayerNode.compile(Vcs.node, [
|
||||
[Bus.node, Layer.succeed(Bus.Service, bus)],
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
]),
|
||||
root,
|
||||
)
|
||||
const vcs = Context.get(context, Vcs.Service)
|
||||
const reads: string[] = []
|
||||
const observed: (string | undefined)[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type !== VcsEvent.BranchUpdated.type) return
|
||||
observed.push(Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch)
|
||||
}),
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
let branch = "initial"
|
||||
let block = false
|
||||
yield* vcs
|
||||
.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
diff: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
|
||||
info: () =>
|
||||
Effect.gen(function* () {
|
||||
const value = branch
|
||||
reads.push(value)
|
||||
if (block) {
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return { branch: { current: value } }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
observed.length = 0
|
||||
|
||||
yield* vcs.diff("branch", { context: 3 })
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
directory,
|
||||
worktree: directory,
|
||||
canonical: directory,
|
||||
mode: "branch",
|
||||
context: 3,
|
||||
maxOutputBytes: 10_000_000,
|
||||
},
|
||||
])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
block = true
|
||||
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered).pipe(Effect.timeout("1 second"), TestClock.withLive)
|
||||
branch = "late"
|
||||
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Effect.yieldNow
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
const snapshot = yield* vcs.info()
|
||||
|
||||
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(first.pollUnsafe()).toBeDefined()
|
||||
expect(second.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
yield* Fiber.join(shutdown)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(observed).toEqual([])
|
||||
expect(yield* vcs.info()).toBe(snapshot)
|
||||
}).pipe(Effect.provide(LayerNode.compile(Bus.node))),
|
||||
)
|
||||
|
||||
it.live("validates provider results and bounds oversized patches", () =>
|
||||
withTmp((directory) =>
|
||||
it.live("serializes filesystem and config refreshes while reading the latest desired provider", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
const bus = yield* Bus.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const accepted = yield* Deferred.make<void>()
|
||||
const reads: string[] = []
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
|
||||
diff: () =>
|
||||
Effect.succeed([
|
||||
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
|
||||
]),
|
||||
id: "git",
|
||||
info: () =>
|
||||
Effect.gen(function* () {
|
||||
reads.push(reads.length === 0 ? "initial" : "filesystem")
|
||||
if (reads.length === 1) return { branch: { current: "initial" } }
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return { branch: { current: "filesystem" } }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
),
|
||||
)
|
||||
const updates = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(2), Stream.runLast, Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
const rows = yield* vcs.diff("working")
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
|
||||
expect(rows[0].additions).toBe(1)
|
||||
}).pipe(provide(directory)),
|
||||
yield* Effect.gen(function* () {
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
yield* Deferred.await(started)
|
||||
const configured = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
id: "git",
|
||||
info: () =>
|
||||
Effect.sync(() => {
|
||||
reads.push("config")
|
||||
return { branch: { current: "config" } }
|
||||
}),
|
||||
status: () => Effect.succeed([{ file: "config.txt", additions: 1, deletions: 0, status: "added" }]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(accepted)
|
||||
expect(reads).toEqual(["initial", "filesystem"])
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(configured)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
|
||||
expect(reads).toEqual(["initial", "filesystem", "config"])
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves provider interruption", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
synthetic.effect("keeps branch streams current when listeners change the selected provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Effect.scope
|
||||
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (
|
||||
event.type !== VcsEvent.BranchUpdated.type ||
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch !== "feature"
|
||||
)
|
||||
return Effect.void
|
||||
return vcs
|
||||
.transform((draft) =>
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "listener" } }) })),
|
||||
)
|
||||
.pipe(Scope.provide(scope), Effect.asVoid)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(provider({ status: () => Effect.never }))
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
}),
|
||||
)
|
||||
|
||||
const fiber = yield* Effect.forkChild(vcs.status())
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
synthetic.effect("does not roll back branch streams when an older listener finishes late", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === VcsEvent.BranchUpdated.type &&
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "older"
|
||||
? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const older = yield* vcs
|
||||
.transform((draft) => {
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "older" } }) }))
|
||||
draft.default.set("custom")
|
||||
})
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "newer" } }) })),
|
||||
)
|
||||
expect(older.pollUnsafe()).toBeUndefined()
|
||||
expect((yield* vcs.info()).branch.current).toBe("newer")
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(older)
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined).pipe(Effect.andThen(unsubscribe))))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("lists local branches by recent activity", () =>
|
||||
|
||||
@@ -101,6 +101,10 @@ await ctx.session.hook("context", (event) => {
|
||||
event.tools.read.description = "Read a file using narrow line ranges."
|
||||
delete event.tools.write
|
||||
})
|
||||
|
||||
await ctx.session.hook("retry", (event) => {
|
||||
if (event.attempt >= 3) event.decision = { retry: false }
|
||||
})
|
||||
```
|
||||
|
||||
Promise tools use complete executable tool values with async executors:
|
||||
|
||||
@@ -98,6 +98,13 @@ yield *
|
||||
delete event.tools.write
|
||||
}),
|
||||
)
|
||||
|
||||
yield *
|
||||
ctx.session.hook("retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.attempt >= 3) event.decision = { retry: false }
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
## Reloading A Domain
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import type { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { JsonSchema, Types } from "effect"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
@@ -52,12 +53,24 @@ export interface SessionHttpResponse {
|
||||
response: Response
|
||||
}
|
||||
|
||||
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
export interface SessionRetry {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly error: SessionError.Error
|
||||
readonly attempt: number
|
||||
decision: SessionRetryDecision
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly prompt: SessionPrompt
|
||||
readonly context: SessionContext
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import type { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { JsonSchema, Types } from "effect"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
@@ -52,12 +53,24 @@ export interface SessionHttpResponse {
|
||||
response: Response
|
||||
}
|
||||
|
||||
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
export interface SessionRetry {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly error: SessionError.Error
|
||||
readonly attempt: number
|
||||
decision: SessionRetryDecision
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly prompt: SessionPrompt
|
||||
readonly context: SessionContext
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { IntegrationID } from "@opencode-ai/schema/integration-id"
|
||||
import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Deferred, Effect, Exit, Fiber, Option, Schema, Scope, Stream } from "effect"
|
||||
import { locationLayer } from "../../core/test/fixture/location"
|
||||
import { it, testEffect } from "../../core/test/lib/effect"
|
||||
import { EventFeed } from "../src/event-feed"
|
||||
|
||||
const Internal = Bus.ephemeral({ type: "test.internal", schema: { value: Schema.String } })
|
||||
const vcsIt = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
[Database.node, Database.configured({ path: ":memory:" })],
|
||||
]),
|
||||
)
|
||||
|
||||
const event = (id: string): Event.Payload<typeof Agent.Event.Updated> => ({
|
||||
id: Event.ID.make(`evt_${id}`),
|
||||
@@ -44,6 +57,55 @@ describe("EventFeed", () => {
|
||||
expect(EventFeed.frame(payload)).toBe(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
})
|
||||
|
||||
vcsIt.effect("delivers the latest VCS branch after an earlier legacy listener reenters", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const provider = {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
info: () => Effect.succeed({ branch: { current: "outer" } }),
|
||||
branches: () => Effect.succeed([]),
|
||||
status: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
}
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === VcsEvent.BranchUpdated.type &&
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "outer"
|
||||
? vcs
|
||||
.transform((draft) =>
|
||||
draft.add({ ...provider, info: () => Effect.succeed({ branch: { current: "inner" } }) }),
|
||||
)
|
||||
.pipe(Scope.provide(scope), Effect.asVoid)
|
||||
: Effect.void,
|
||||
)
|
||||
// Unsubscribe before registration teardown can restore "outer" and reenter the listener.
|
||||
yield* Effect.gen(function* () {
|
||||
const feed = yield* EventFeed.make(bus.listen, {
|
||||
encode: (event) => (event.type === VcsEvent.BranchUpdated.type ? (event.data.branch ?? "none") : event.type),
|
||||
})
|
||||
const stream = yield* feed.subscribe
|
||||
const received = yield* stream.pipe(
|
||||
Stream.takeUntil((frame) => frame === Agent.Event.Updated.type, { excludeLast: true }),
|
||||
Stream.runLast,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(provider)
|
||||
draft.default.set(provider.id)
|
||||
})
|
||||
yield* unsubscribe
|
||||
yield* bus.publish(Agent.Event.Updated, {})
|
||||
|
||||
const info = yield* vcs.info()
|
||||
expect(info.branch.current).toBe("inner")
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(received))).toBe(info.branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("encodes once and delivers the same frame to every subscriber", () =>
|
||||
Effect.gen(function* () {
|
||||
let encodes = 0
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"bench:diff": "bun run --conditions=browser script/bench-diff.tsx",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,175 +0,0 @@
|
||||
/** @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)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { createMemo, For, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { createMemo, For, Show, splitProps } from "solid-js"
|
||||
import { splitPatchHunks } from "../util/diff"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
@@ -20,10 +20,6 @@ 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()]
|
||||
@@ -32,31 +28,28 @@ export function PatchDiff(props: Props) {
|
||||
.filter((node) => !node.isDestroyed),
|
||||
})
|
||||
const syncGutters = (attempt = 0) => {
|
||||
// Registrations share one whole-file pass instead of repeating it for every hunk.
|
||||
if (gutterFrame !== undefined) return
|
||||
gutterFrame = requestAnimationFrame(() => {
|
||||
gutterFrame = undefined
|
||||
requestAnimationFrame(() => {
|
||||
const sides = [...nodes.values()]
|
||||
.filter((item) => !item.isDestroyed)
|
||||
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
|
||||
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 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 ?? "")),
|
||||
),
|
||||
)
|
||||
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) })
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
BoxRenderable,
|
||||
CodeRenderable,
|
||||
DiffRenderable,
|
||||
ImageRenderable,
|
||||
MouseButton,
|
||||
@@ -33,7 +32,6 @@ 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([])
|
||||
@@ -82,154 +80,6 @@ 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 {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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}"`,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NonEmptyReadonlyArray } from "effect/Array"
|
||||
import { isArrayNonEmpty } from "effect/Array"
|
||||
import * as NodeSink from "@effect/platform-node/NodeSink"
|
||||
import * as NodeStream from "@effect/platform-node/NodeStream"
|
||||
import { Deferred, Effect, Exit, FileSystem, Layer, Path, PlatformError, Predicate, Sink, Stream } from "effect"
|
||||
@@ -60,10 +60,9 @@ const flatten = (command: ChildProcess.Command) => {
|
||||
}
|
||||
|
||||
walk(command)
|
||||
if (commands.length === 0) throw new Error("flatten produced empty commands array")
|
||||
const [head, ...tail] = commands
|
||||
if (!isArrayNonEmpty(commands)) throw new Error("flatten produced empty commands array")
|
||||
return {
|
||||
commands: [head, ...tail] as NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
|
||||
commands,
|
||||
opts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,6 +1128,35 @@ effect: (ctx) =>
|
||||
}),
|
||||
```
|
||||
|
||||
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
|
||||
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
|
||||
internally performs the next attempt.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.session.hook("retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.error.status === 429) {
|
||||
event.decision = { retry: true, delay: 10_000 }
|
||||
return
|
||||
}
|
||||
if (event.error.type === "provider.invalid-request" && event.attempt === 2) {
|
||||
event.decision = { retry: true, delay: 0 }
|
||||
return
|
||||
}
|
||||
if (event.attempt >= 3) event.decision = { retry: false }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
```
|
||||
|
||||
The initial `decision` is OpenCode's policy, so hooks may make a normally terminal provider failure retryable or veto a
|
||||
proposed retry. Multiple hooks run in registration order and later hooks see the current decision. The built-in maximum
|
||||
attempt count remains a hard limit. `attempt` is the physical attempt being proposed; the initial request is attempt `1`,
|
||||
so the first retry is attempt `2`. Invalid delays (`NaN`, infinity, or negative values) fall back to the computed delay.
|
||||
Context-overflow recovery remains separate because it compacts the conversation instead of retrying the same request.
|
||||
|
||||
#### Reference
|
||||
|
||||
```ts
|
||||
@@ -1136,6 +1165,18 @@ interface SessionHooks {
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
type RetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
interface SessionRetry {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
readonly error: { type: string; message: string; status?: number }
|
||||
readonly attempt: number
|
||||
decision: RetryDecision
|
||||
}
|
||||
|
||||
interface SessionHookDomain {
|
||||
|
||||
@@ -95,10 +95,10 @@ Pass plugin options with the object form in `opencode.json(c)`.
|
||||
{
|
||||
"package": "./plugins/company.ts",
|
||||
"options": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
]
|
||||
"strict": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
@@ -120,6 +120,10 @@ export default Plugin.define({
|
||||
Transforms are a central pattern in the plugin API and modify how OpenCode works. Plugins register
|
||||
transforms and each builds on the changes made before it.
|
||||
|
||||
Transform callbacks are synchronous and should only edit their draft. Load external data before registering or
|
||||
reloading a transform. Registry reads replay pending changes when needed, so definitions registered earlier in
|
||||
setup are readable without waiting for all plugins to finish setup. Update notifications are batched separately.
|
||||
|
||||
Say we have a plugin that adds one model to the catalog.
|
||||
|
||||
```ts title="plugins/models.ts"
|
||||
@@ -159,8 +163,7 @@ export default Plugin.define({
|
||||
})
|
||||
```
|
||||
|
||||
Now say the first plugin dynamically fetches can fetch its model list from a
|
||||
dynamic source. It can call `reload` when that list changes.
|
||||
Now say the first plugin fetches its model list from a dynamic source. It can call `reload` when that list changes.
|
||||
|
||||
```ts title="plugins/models.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
@@ -190,7 +193,12 @@ export default Plugin.define({
|
||||
})
|
||||
```
|
||||
|
||||
`reload` replays every catalog transform in order, so the output-price policy still filters the refreshed models.
|
||||
`reload` invalidates the catalog without changing transform order, so the output-price policy still filters the
|
||||
refreshed models. Reads replay those changes immediately rather than waiting for the update notification's
|
||||
500 ms debounce. If nothing reads the catalog, it is rebuilt before the notification instead.
|
||||
|
||||
Reading definitions does not start or await background resource work. MCP connections, Git reference checkouts,
|
||||
and cached VCS information retain their own lifecycle and readiness behavior.
|
||||
|
||||
## API
|
||||
|
||||
@@ -468,14 +476,23 @@ interface IntegrationContext {
|
||||
key(input: IntegrationConnectKeyInput, requestOptions?: RequestOptions): Promise<void>
|
||||
}
|
||||
oauth: {
|
||||
connect(input: IntegrationOauthConnectInput, requestOptions?: RequestOptions): Promise<IntegrationOauthConnectOutput>
|
||||
connect(
|
||||
input: IntegrationOauthConnectInput,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<IntegrationOauthConnectOutput>
|
||||
status(input: IntegrationOauthStatusInput, requestOptions?: RequestOptions): Promise<IntegrationOauthStatusOutput>
|
||||
complete(input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions): Promise<void>
|
||||
cancel(input: IntegrationOauthCancelInput, requestOptions?: RequestOptions): Promise<void>
|
||||
}
|
||||
command: {
|
||||
connect(input: IntegrationCommandConnectInput, requestOptions?: RequestOptions): Promise<IntegrationCommandConnectOutput>
|
||||
status(input: IntegrationCommandStatusInput, requestOptions?: RequestOptions): Promise<IntegrationCommandStatusOutput>
|
||||
connect(
|
||||
input: IntegrationCommandConnectInput,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<IntegrationCommandConnectOutput>
|
||||
status(
|
||||
input: IntegrationCommandStatusInput,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<IntegrationCommandStatusOutput>
|
||||
cancel(input: IntegrationCommandCancelInput, requestOptions?: RequestOptions): Promise<void>
|
||||
}
|
||||
transform(callback: (draft: IntegrationDraft) => void): Promise<Registration>
|
||||
@@ -1104,6 +1121,30 @@ await ctx.session.hook("http.response", (event) => {
|
||||
})
|
||||
```
|
||||
|
||||
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
|
||||
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
|
||||
internally performs the next attempt.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("retry", (event) => {
|
||||
if (event.error.status === 429) {
|
||||
event.decision = { retry: true, delay: 10_000 }
|
||||
return
|
||||
}
|
||||
if (event.error.type === "provider.invalid-request" && event.attempt === 2) {
|
||||
event.decision = { retry: true, delay: 0 }
|
||||
return
|
||||
}
|
||||
if (event.attempt >= 3) event.decision = { retry: false }
|
||||
})
|
||||
```
|
||||
|
||||
The initial `decision` is OpenCode's policy, so hooks may make a normally terminal provider failure retryable or veto a
|
||||
proposed retry. Multiple hooks run in registration order and later hooks see the current decision. The built-in maximum
|
||||
attempt count remains a hard limit. `attempt` is the physical attempt being proposed; the initial request is attempt `1`,
|
||||
so the first retry is attempt `2`. Invalid delays (`NaN`, infinity, or negative values) fall back to the computed delay.
|
||||
Context-overflow recovery remains separate because it compacts the conversation instead of retrying the same request.
|
||||
|
||||
#### Reference
|
||||
|
||||
```ts
|
||||
@@ -1115,6 +1156,18 @@ interface SessionHooks {
|
||||
"model.request": SessionModelRequestHook
|
||||
"http.request": SessionHttpRequestHook
|
||||
"http.response": SessionHttpResponseHook
|
||||
retry: SessionRetryHook
|
||||
}
|
||||
|
||||
type RetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
interface SessionRetryHook {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
readonly error: { type: string; message: string; status?: number }
|
||||
readonly attempt: number
|
||||
decision: RetryDecision
|
||||
}
|
||||
|
||||
interface SessionContextHook {
|
||||
@@ -1201,10 +1254,7 @@ await ctx.shell.hook("create.before", (event) => {
|
||||
|
||||
```ts
|
||||
interface ShellHookContext {
|
||||
hook(
|
||||
name: "create.before",
|
||||
callback: (event: ShellCreateBefore) => Promise<void> | void,
|
||||
): Promise<Registration>
|
||||
hook(name: "create.before", callback: (event: ShellCreateBefore) => Promise<void> | void): Promise<Registration>
|
||||
}
|
||||
|
||||
interface ShellCreateBefore {
|
||||
|
||||
Reference in New Issue
Block a user