Compare commits

..
45 changed files with 2420 additions and 1317 deletions
+14 -44
View File
@@ -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
View File
@@ -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",
@@ -1367,7 +1367,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
@@ -1496,7 +1496,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
-2
View File
@@ -1,7 +1,5 @@
/// <reference types="@solidjs/start/env" />
import "@solidjs/start"
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
+5 -1
View File
@@ -859,12 +859,16 @@ export function configured(options?: Options) {
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(Stream.concat(Stream.make(marker)))
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
Stream.filter((target) => target > sequence),
Stream.flatMap((target) => readThrough(target)),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, live)
}),
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
: undefined
on = on(
new Proxy(
getTableColumnsRuntime(this.config.table),
this.config.table._.columns,
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
+236
View File
@@ -0,0 +1,236 @@
export * as StateMachine from "./state-machine.js"
import { Cause, Effect, Exit, Fiber, Queue, type Scope } from "effect"
export type Command<Operation> =
| {
readonly _tag: "Invoke"
readonly id: string
readonly operation: Operation
}
| {
readonly _tag: "Stop"
readonly id: string
}
| {
readonly _tag: "StopAndJoin"
readonly id: string
readonly ids: ReadonlyArray<string>
readonly waitFor: ReadonlyArray<string>
}
export type InvocationExited<Event, Operation, Error> = {
readonly _tag: "InvocationExited"
readonly id: string
readonly generation: number
readonly operation: Operation
readonly exit: Exit.Exit<Event, Error>
}
export type RuntimeEvent<Event, Operation, Error> =
| {
readonly _tag: "Input"
readonly input: Event
readonly cause?: Cause.Cause<never>
}
| InvocationExited<Event, Operation, Error>
| {
readonly _tag: "InvocationsStopped"
readonly id: string
readonly exits: ReadonlyArray<InvocationExited<Event, Operation, Error>>
}
export type Continue<State, Operation> = {
readonly _tag: "Continue"
readonly state: State
readonly commands: ReadonlyArray<Command<Operation>>
}
export type Decision<State, Operation, Output> =
| Continue<State, Operation>
| {
readonly _tag: "Done"
readonly output: Output
}
export type Definition<State, Event, Operation, Error, Output> = {
readonly initial: Continue<State, Operation>
readonly transition: (
state: State,
event: RuntimeEvent<Event, Operation, Error>,
) => Decision<State, Operation, Output>
readonly interruption?: Event
}
export type Executor<Event, Operation, Error, Requirements> = (
operation: Operation,
) => Effect.Effect<Event, Error, Requirements>
export function define<State, Event, Operation, Error, Output>(
definition: Definition<State, Event, Operation, Error, Output>,
) {
return definition
}
export function next<State, Operation = never>(state: State, ...commands: ReadonlyArray<Command<Operation>>) {
return { _tag: "Continue", state, commands } as const
}
export function done<Output>(output: Output) {
return { _tag: "Done", output } as const
}
export function invoke<Operation>(id: string, operation: Operation): Command<Operation> {
return { _tag: "Invoke", id, operation }
}
export function stop(id: string): Command<never> {
return { _tag: "Stop", id }
}
/** Stops `ids`, awaits `waitFor` without interruption, and delivers their exits as one batch. */
export function stopAndJoin(
id: string,
ids: ReadonlyArray<string>,
waitFor: ReadonlyArray<string> = [],
): Command<never> {
return { _tag: "StopAndJoin", id, ids, waitFor }
}
export const run = Effect.fn("StateMachine.run")(function* <State, Event, Operation, Error, Output, Requirements>(
definition: Definition<State, Event, Operation, Error, Output>,
execute: Executor<Event, Operation, Error, Requirements>,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.scoped(
Effect.gen(function* () {
const queue = yield* Queue.unbounded<RuntimeEvent<Event, Operation, Error>>()
const invocations = new Map<
string,
{
readonly generation: number
readonly operation: Operation
readonly fiber: Fiber.Fiber<Event, Error>
}
>()
let generation = 0
const executeCommands = Effect.fnUntraced(function* (
commands: ReadonlyArray<Command<Operation>>,
interruptibleExecution: boolean,
) {
yield* Effect.forEach(
commands,
(command) =>
Effect.gen(function* () {
if (command._tag === "Stop") {
const invocation = invocations.get(command.id)
yield* invocation
? Fiber.interrupt(invocation.fiber)
: Effect.die(new Error(`Unknown state machine invocation: ${command.id}`))
return
}
if (command._tag === "StopAndJoin") {
const captured = [...command.ids, ...command.waitFor].flatMap((id) => {
const invocation = invocations.get(id)
return invocation ? [{ id, ...invocation }] : []
})
if (captured.length !== command.ids.length + command.waitFor.length)
yield* Effect.die(new Error("Unknown state machine invocation in StopAndJoin"))
// Invalidate individual exits, including ones already queued, before interrupting.
captured.forEach((invocation) => invocations.delete(invocation.id))
yield* Fiber.interruptAll(captured.slice(0, command.ids.length).map((invocation) => invocation.fiber))
const exits = yield* Effect.forEach(captured, (invocation) =>
Fiber.await(invocation.fiber).pipe(
Effect.map((exit) => ({
_tag: "InvocationExited" as const,
id: invocation.id,
generation: invocation.generation,
operation: invocation.operation,
exit,
})),
),
)
yield* Queue.offer(queue, { _tag: "InvocationsStopped", id: command.id, exits })
return
}
const previous = invocations.get(command.id)
if (previous) yield* Fiber.interrupt(previous.fiber)
generation += 1
const current = generation
const execution = interruptibleExecution
? restore(execute(command.operation))
: execute(command.operation)
const fiber = yield* execution.pipe(Effect.forkScoped({ startImmediately: false }))
invocations.set(command.id, { generation: current, operation: command.operation, fiber })
// A deferred child may be interrupted before an Effect.onExit observer starts.
fiber.addObserver((exit) => {
Queue.offerUnsafe(queue, {
_tag: "InvocationExited",
id: command.id,
generation: current,
operation: command.operation,
exit,
})
})
}),
{ discard: true },
)
})
const handleInterruption = (
state: State,
cause: Cause.Cause<never>,
): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
if (!Cause.hasInterruptsOnly(cause) || definition.interruption === undefined)
return yield* Effect.failCause(cause)
return yield* dispatch(
definition.transition(state, {
_tag: "Input",
input: definition.interruption,
cause,
}),
true,
)
})
const dispatch = (
decision: Decision<State, Operation, Output>,
interrupted: boolean,
): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
if (decision._tag === "Done") return decision.output
yield* executeCommands(decision.commands, !interrupted)
if (interrupted) return yield* Effect.suspend(() => loop(decision.state, true))
const boundary = yield* restore(Effect.void).pipe(Effect.exit)
if (Exit.isFailure(boundary)) return yield* handleInterruption(decision.state, boundary.cause)
return yield* Effect.suspend(() => loop(decision.state, false))
})
const loop = (state: State, interrupted: boolean): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
Effect.gen(function* () {
const received = yield* (interrupted ? Queue.take(queue) : restore(Queue.take(queue))).pipe(Effect.exit)
if (Exit.isFailure(received)) return yield* handleInterruption(state, received.cause)
if (received.value._tag === "InvocationExited") {
const invocation = invocations.get(received.value.id)
if (!invocation || invocation.generation !== received.value.generation) {
return yield* Effect.suspend(() => loop(state, interrupted))
}
invocations.delete(received.value.id)
}
return yield* dispatch(definition.transition(state, received.value), interrupted)
})
return yield* dispatch(definition.initial, false)
}),
),
)
})
@@ -653,11 +653,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
}
if (isActiveText) {
controller.enqueue({
type: "text-end",
id: "txt-0",
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
controller.enqueue({ type: "text-end", id: "txt-0" })
}
// go through all tool calls and send the ones that are not finished
-1
View File
@@ -37,7 +37,6 @@ export function buildLocationServiceMap(
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
}),
),
+1 -3
View File
@@ -84,9 +84,7 @@ export const Plugin = define({
})
function append(template: string, input: string) {
const value = input.trim()
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
return [template, value].filter(Boolean).join("\n\n")
return [template, input.trim()].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
+9 -5
View File
@@ -134,9 +134,10 @@ const layer = Layer.effect(
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) return rows.slice(0, input.limit)
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
@@ -146,7 +147,7 @@ const layer = Layer.effect(
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return code === 1 ? [] : rows
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
@@ -177,7 +178,7 @@ const layer = Layer.effect(
parse: (line) => Effect.succeed(normalizePath(line)),
}).pipe(
Effect.map((result) =>
result.map((relative) =>
result.items.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
@@ -211,7 +212,10 @@ const layer = Layer.effect(
)
},
onItem: input.onEntry,
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<RawMatchData>({
...input,
@@ -244,7 +248,7 @@ const layer = Layer.effect(
),
}).pipe(
Effect.map((result) =>
result.map((match) =>
result.items.map((match) =>
Match.make({
entry: Entry.make({
path: RelativePath.make(match.path.text),
+72 -79
View File
@@ -22,6 +22,7 @@ import { llmClient } from "../../effect/app-node-platform.js"
import { StepFailedError } from "../error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionStep } from "./step.js"
import { SessionStepMachine } from "./step-machine.js"
import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
@@ -167,91 +168,83 @@ const layer = Layer.effect(
return selected
})
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
/** Owns logical Step policy; each attempt owns provider observation, tools, and durable settlement. */
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))
let initial: SessionContext.Loaded | undefined = first
let recoverOverflow = true
let recoverContinuation = true
while (true) {
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
}
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
continue
}
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* context.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
const outcome = yield* steps.attempt({
sessionID,
assistantMessageID,
agent: loaded.agent.id,
model: loaded.model,
prepared,
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
})
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)),
return yield* SessionStepMachine.run(SessionMessage.ID.create(), {
prepare: Effect.fnUntraced(function* (state) {
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
}
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
return SessionStepMachine.Preparation.Rebuilt()
}
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* context.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
return SessionStepMachine.Preparation.Ready({
attempt: yield* steps.open({
sessionID,
assistantMessageID: state.assistantMessageID,
agent: loaded.agent.id,
model: loaded.model,
prepared,
recoverContinuation: state.recoverContinuation,
recoverOverflow: Effect.suspend(() =>
compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
Effect.asVoid,
}),
})
}),
retry: (state, outcome) =>
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID: state.assistantMessageID }).pipe(
Pull.catchDone(() =>
outcome._tag === "Retry"
? bus
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID: state.assistantMessageID,
error: outcome.error,
})
.pipe(Effect.andThen(outcome.cause))
: outcome.cause,
),
Continue: Effect.fnUntraced(function* (outcome) {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() => outcome.cause),
)
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
assistantMessageID = SessionMessage.ID.create()
}),
Compacted: Effect.fnUntraced(function* () {
recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
}),
RecoverFull: Effect.fnUntraced(function* () {
recoverContinuation = false
}),
})
if (completed !== undefined) return completed
}
Effect.asVoid,
),
publishSynthetic: bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
}),
})
})
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
@@ -0,0 +1,402 @@
export * as SessionStepMachine from "./step-machine.js"
import { AIError, type ToolCall } from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit } from "effect"
import { StateMachine } from "../../effect/state-machine.js"
import { StepFailedError } from "../error.js"
import { SessionMessage } from "../message.js"
import { SessionStep } from "./step.js"
const PREPARATION = "preparation"
const PROVIDER = "provider"
const COMPACTION = "compaction"
const SETTLEMENT = "settlement"
const RETRY = "retry"
export type Context = {
readonly assistantMessageID: SessionMessage.ID
readonly recoverOverflow: boolean
readonly recoverContinuation: boolean
}
export type Preparation = Data.TaggedEnum<{
Rebuilt: {}
Ready: { readonly attempt: SessionStep.Attempt }
}>
export const Preparation = Data.taggedEnum<Preparation>()
type AttemptFailure = AIError | StepFailedError
type BackoffOutcome = Data.TaggedEnum.Value<SessionStep.Outcome, "Retry" | "Continue">
type ToolRun = {
readonly call: ToolCall
readonly exit?: SessionStep.ToolExit
}
type ActiveAttempt = {
readonly context: Context
readonly attempt: SessionStep.Attempt
readonly tools: ReadonlyMap<string, ToolRun>
}
type AttemptState = Data.TaggedEnum<{
ObservingProvider: { readonly active: ActiveAttempt }
FinalizingProvider: {
readonly active: ActiveAttempt
readonly stream: Exit.Exit<void, AIError>
readonly stopping?: Cause.Cause<never>
}
AwaitingTools: { readonly active: ActiveAttempt; readonly stream: Exit.Exit<void, AIError> }
RecoveringOverflow: { readonly active: ActiveAttempt; readonly stream: Exit.Exit<void, AIError> }
}>
export type State =
| AttemptState
| Data.TaggedEnum<{
PreparingAttempt: { readonly context: Context }
SettlingAttempt: { readonly active: ActiveAttempt; readonly stopping?: Cause.Cause<never> }
BackingOff: {
readonly context: Context
readonly outcome: BackoffOutcome
}
Stopping: { readonly from?: AttemptState; readonly cause: Cause.Cause<never> }
}>
export const State = Data.taggedEnum<State>()
export type Event<Failure> = Data.TaggedEnum<{
Prepared: { readonly exit: Exit.Exit<{ readonly context: Context; readonly preparation: Preparation }, Failure> }
ProviderObserved: { readonly exit: Exit.Exit<SessionStep.ProviderObservation, AIError> }
ToolFinished: { readonly call: ToolCall; readonly exit: SessionStep.ToolExit }
ProviderFinished: { readonly exit: Exit.Exit<void> }
OverflowRecovered: { readonly exit: Exit.Exit<boolean> }
AttemptSettled: { readonly exit: Exit.Exit<SessionStep.Outcome, AttemptFailure> }
RetryFinished: { readonly exit: Exit.Exit<void, Failure> }
CancelRequested: {}
}>
interface EventDefinition extends Data.TaggedEnum.WithGenerics<1> {
readonly taggedEnum: Event<this["A"]>
}
export const Event = Data.taggedEnum<EventDefinition>()
export type Operation = Data.TaggedEnum<{
PrepareAttempt: { readonly context: Context; readonly freshAssistant: boolean }
ObserveProvider: { readonly attempt: SessionStep.Attempt }
RunTool: { readonly attempt: SessionStep.Attempt; readonly call: ToolCall }
FinishProvider: { readonly attempt: SessionStep.Attempt; readonly stream: Exit.Exit<void, AIError> }
RecoverOverflow: { readonly attempt: SessionStep.Attempt; readonly settlement: SessionStep.Settlement }
SettleAttempt: { readonly attempt: SessionStep.Attempt; readonly settlement: SessionStep.Settlement }
Retry: {
readonly context: Context
readonly outcome: BackoffOutcome
}
}>
export const Operation = Data.taggedEnum<Operation>()
export type Capabilities<Failure, RetryFailure, Requirements> = {
readonly prepare: (context: Context) => Effect.Effect<Preparation, Failure, Requirements>
readonly retry: (context: Context, outcome: BackoffOutcome) => Effect.Effect<void, RetryFailure, Requirements>
readonly publishSynthetic: Effect.Effect<void, Failure, Requirements>
}
export const run = Effect.fn("SessionStepMachine.run")(function* <Failure, RetryFailure, Requirements>(
assistantMessageID: SessionMessage.ID,
capabilities: Capabilities<Failure, RetryFailure, Requirements>,
) {
const execute = Operation.$match({
PrepareAttempt: (operation) =>
Effect.suspend(() => {
const context = operation.freshAssistant
? { ...operation.context, assistantMessageID: SessionMessage.ID.create() }
: operation.context
return capabilities.prepare(context).pipe(Effect.map((preparation) => ({ context, preparation })))
}).pipe(
Effect.exit,
Effect.map((exit) => Event.Prepared({ exit })),
),
ObserveProvider: (operation) =>
operation.attempt.observeUntilBoundary().pipe(
Effect.exit,
Effect.map((exit) => Event.ProviderObserved({ exit })),
),
RunTool: (operation) =>
operation.attempt.runTool(operation.call).pipe(
Effect.exit,
Effect.map((exit) => Event.ToolFinished({ call: operation.call, exit })),
),
FinishProvider: (operation) =>
operation.attempt.finishProvider(operation.stream).pipe(
Effect.exit,
Effect.map((exit) => Event.ProviderFinished({ exit })),
),
RecoverOverflow: (operation) =>
operation.attempt.recoverOverflow(operation.settlement).pipe(
Effect.exit,
Effect.map((exit) => Event.OverflowRecovered({ exit })),
),
SettleAttempt: (operation) =>
operation.attempt.settle(operation.settlement).pipe(
Effect.exit,
Effect.map((exit) => Event.AttemptSettled({ exit })),
),
Retry: (operation) =>
capabilities.retry(operation.context, operation.outcome).pipe(
Effect.andThen(operation.outcome._tag === "Continue" ? capabilities.publishSynthetic : Effect.void),
Effect.exit,
Effect.map((exit) => Event.RetryFinished({ exit })),
),
})
const result = yield* StateMachine.run(definition<Failure, RetryFailure>(assistantMessageID), execute)
return yield* result
})
export const definition = <Failure, RetryFailure>(assistantMessageID: SessionMessage.ID) => {
const context = {
assistantMessageID,
recoverOverflow: true,
recoverContinuation: true,
}
type MachineFailure = Failure | RetryFailure | AttemptFailure
type Decision = StateMachine.Decision<State, Operation, Exit.Exit<boolean, MachineFailure>>
const prepare = (context: Context, freshAssistant = false): StateMachine.Continue<State, Operation> =>
StateMachine.next(
State.PreparingAttempt({ context }),
StateMachine.invoke(PREPARATION, Operation.PrepareAttempt({ context, freshAssistant })),
)
const pull = (active: ActiveAttempt): Decision =>
StateMachine.next(
State.ObservingProvider({ active }),
StateMachine.invoke(PROVIDER, Operation.ObserveProvider({ attempt: active.attempt })),
)
const settlement = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>): SessionStep.Settlement => ({
stream,
tools: Array.from(active.tools.values()).flatMap((tool) =>
tool.exit ? [{ call: tool.call, exit: tool.exit }] : [],
),
})
const settle = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>, stopping?: Cause.Cause<never>): Decision =>
StateMachine.next(
State.SettlingAttempt({ active, stopping }),
StateMachine.invoke(
SETTLEMENT,
Operation.SettleAttempt({
attempt: active.attempt,
settlement: settlement(active, stream),
}),
),
)
const afterProvider = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>): Decision => {
if (Array.from(active.tools.values()).some((tool) => tool.exit === undefined))
return StateMachine.next(State.AwaitingTools({ active, stream }))
if (!active.context.recoverOverflow) return settle(active, stream)
return StateMachine.next(
State.RecoveringOverflow({ active, stream }),
StateMachine.invoke(
COMPACTION,
Operation.RecoverOverflow({
attempt: active.attempt,
settlement: settlement(active, stream),
}),
),
)
}
const finishProvider = (
active: ActiveAttempt,
stream: Exit.Exit<void, AIError>,
stopping?: Cause.Cause<never>,
): Decision =>
StateMachine.next(
State.FinalizingProvider({ active, stream, stopping }),
StateMachine.invoke(
PROVIDER,
Operation.FinishProvider({
attempt: active.attempt,
stream,
}),
),
)
const stop = (cause: Cause.Cause<never>, ids: ReadonlyArray<string>, from?: AttemptState): Decision => {
return StateMachine.next(
State.Stopping({ cause, from }),
StateMachine.stopAndJoin("step", ids, from?._tag === "FinalizingProvider" ? [PROVIDER] : []),
)
}
const interrupt = (state: State, cause: Cause.Cause<never>): Decision => {
const stopAttempt = (state: Exclude<AttemptState, { readonly _tag: "RecoveringOverflow" }>) =>
stop(
cause,
[
...(state._tag === "ObservingProvider" ? [PROVIDER] : []),
...Array.from(state.active.tools.values()).flatMap((tool) =>
tool.exit === undefined ? [toolID(tool.call)] : [],
),
],
state,
)
return State.$match(state, {
PreparingAttempt: () => stop(cause, [PREPARATION]),
ObservingProvider: stopAttempt,
FinalizingProvider: stopAttempt,
AwaitingTools: stopAttempt,
SettlingAttempt: (state) => StateMachine.next(State.SettlingAttempt({ active: state.active, stopping: cause })),
RecoveringOverflow: (state) => stop(cause, [COMPACTION], state),
BackingOff: () => stop(cause, [RETRY]),
Stopping: (state) => StateMachine.next(state),
})
}
return StateMachine.define<
State,
Event<Failure | RetryFailure>,
Operation,
never,
Exit.Exit<boolean, MachineFailure>
>({
initial: prepare(context),
interruption: Event.CancelRequested(),
transition: (state, runtimeEvent): Decision => {
if (runtimeEvent._tag === "Input") return interrupt(state, runtimeEvent.cause ?? Cause.interrupt(undefined))
if (runtimeEvent._tag === "InvocationsStopped") {
if (state._tag !== "Stopping") return unexpected(state, runtimeEvent)
if (!state.from) return StateMachine.done(Exit.failCause(state.cause))
const finished = runtimeEvent.exits.map(completed)
if (state.from._tag === "RecoveringOverflow") {
const recovered = finished.some(
(event) => event._tag === "OverflowRecovered" && Exit.isSuccess(event.exit) && event.exit.value,
)
return recovered
? StateMachine.done(Exit.failCause(state.cause))
: settle(state.from.active, Exit.failCause(state.cause), state.cause)
}
const tools = new Map(state.from.active.tools)
finished.forEach((event) => {
if (event._tag === "ToolFinished") tools.set(event.call.id, { call: event.call, exit: event.exit })
})
const active = { ...state.from.active, tools }
if (state.from._tag === "ObservingProvider")
return finishProvider(active, Exit.failCause(state.cause), state.cause)
const provider = finished.find((event) => event._tag === "ProviderFinished")
const stream =
provider && Exit.isFailure(provider.exit) ? Exit.failCause(provider.exit.cause) : state.from.stream
return settle(active, stream, state.cause)
}
const event = completed(runtimeEvent)
if (event._tag === "ToolFinished") {
if (
state._tag === "ObservingProvider" ||
state._tag === "FinalizingProvider" ||
state._tag === "AwaitingTools"
) {
const tools = new Map(state.active.tools)
tools.set(event.call.id, { call: event.call, exit: event.exit })
const active = { ...state.active, tools }
return state._tag === "AwaitingTools"
? afterProvider(active, state.stream)
: StateMachine.next({ ...state, active })
}
return unexpected(state, event)
}
return State.$match(state, {
PreparingAttempt: (state) => {
if (event._tag !== "Prepared") return unexpected(state, event)
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
if (event.exit.value.preparation._tag === "Rebuilt") return prepare(event.exit.value.context, true)
const active = {
context: event.exit.value.context,
attempt: event.exit.value.preparation.attempt,
tools: new Map<string, ToolRun>(),
}
return pull(active)
},
ObservingProvider: (state) => {
if (event._tag !== "ProviderObserved") return unexpected(state, event)
if (Exit.isFailure(event.exit)) return finishProvider(state.active, Exit.failCause(event.exit.cause))
const observed = event.exit.value
if (observed._tag === "ProviderEnd") return finishProvider(state.active, Exit.succeed(undefined))
const tools = new Map(state.active.tools)
tools.set(observed.call.id, { call: observed.call })
const next = { ...state.active, tools }
return StateMachine.next(
State.ObservingProvider({ active: next }),
StateMachine.invoke<Operation>(
toolID(observed.call),
Operation.RunTool({
attempt: next.attempt,
call: observed.call,
}),
),
StateMachine.invoke<Operation>(PROVIDER, Operation.ObserveProvider({ attempt: next.attempt })),
)
},
FinalizingProvider: (state) => {
if (event._tag !== "ProviderFinished") return unexpected(state, event)
const stream = Exit.isFailure(event.exit) ? Exit.failCause(event.exit.cause) : state.stream
return state.stopping ? settle(state.active, stream, state.stopping) : afterProvider(state.active, stream)
},
RecoveringOverflow: (state) => {
if (event._tag !== "OverflowRecovered") return unexpected(state, event)
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
if (!event.exit.value) return settle(state.active, state.stream)
const context = { ...state.active.context, recoverOverflow: false }
return prepare(context, true)
},
SettlingAttempt: (state) => {
if (event._tag !== "AttemptSettled") return unexpected(state, event)
if (state.stopping) return StateMachine.done(Exit.failCause(state.stopping))
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
const backoff = (outcome: BackoffOutcome) =>
StateMachine.next(
State.BackingOff({ context: state.active.context, outcome }),
StateMachine.invoke(RETRY, Operation.Retry({ context: state.active.context, outcome })),
)
return SessionStep.Outcome.$match(event.exit.value, {
Completed: (outcome) => StateMachine.done(Exit.succeed(outcome.needsContinuation)),
Retry: backoff,
Continue: backoff,
RecoverFull: () => prepare({ ...state.active.context, recoverContinuation: false }),
})
},
BackingOff: (state) => {
if (event._tag !== "RetryFinished") return unexpected(state, event)
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
return prepare(state.context, state.outcome._tag === "Continue")
},
AwaitingTools: (state) => unexpected(state, event),
Stopping: (state) => unexpected(state, event),
})
},
})
}
const toolID = (call: ToolCall) => `tool:${call.id}`
// Pre-start interruption can bypass the interpreter's Effect.exit.
// Normalize outer failures once without erasing operation-specific error types.
function completed<Failure>(
invocation: StateMachine.InvocationExited<Event<Failure>, Operation, never>,
): Event<Failure> {
if (Exit.isSuccess(invocation.exit)) return invocation.exit.value
const exit = Exit.failCause(invocation.exit.cause)
return Operation.$match(invocation.operation, {
PrepareAttempt: () => Event.Prepared({ exit }),
ObserveProvider: () => Event.ProviderObserved({ exit }),
RunTool: (operation) => Event.ToolFinished({ call: operation.call, exit }),
FinishProvider: () => Event.ProviderFinished({ exit }),
RecoverOverflow: () => Event.OverflowRecovered({ exit }),
SettleAttempt: () => Event.AttemptSettled({ exit }),
Retry: () => Event.RetryFinished({ exit }),
})
}
function unexpected(state: State, event: { readonly _tag: string }): never {
throw new Error(`Unexpected ${event._tag} event while Session Step machine is ${state._tag}`)
}
+208 -178
View File
@@ -9,13 +9,12 @@ import {
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { Cause, Data, Effect, Exit, Option, Pull, Scope, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Agent } from "../../agent.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { Snapshot } from "../../snapshot.js"
import { Tool } from "../../tool.js"
import { ToolOutput } from "../../tool-output.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { StepFailedError } from "../error.js"
@@ -34,11 +33,10 @@ export type Outcome = Data.TaggedEnum<{
Retry: { readonly cause: AIError; readonly error: SessionError.Error }
Continue: { readonly cause: AIError; readonly error: SessionError.Error }
RecoverFull: {}
Compacted: {}
}>
export const Outcome = Data.taggedEnum<Outcome>()
interface Input {
export interface Input {
readonly sessionID: SessionSchema.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
@@ -49,6 +47,27 @@ interface Input {
readonly recoverOverflow: Effect.Effect<boolean>
}
export type ProviderObservation = Data.TaggedEnum<{
ToolCall: { readonly call: ToolCall }
ProviderEnd: {}
}>
export const ProviderObservation = Data.taggedEnum<ProviderObservation>()
export type ToolExit = Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>
export interface Settlement {
readonly stream: Exit.Exit<void, AIError>
readonly tools: ReadonlyArray<{ readonly call: ToolCall; readonly exit: ToolExit }>
}
export interface Attempt {
readonly observeUntilBoundary: () => Effect.Effect<ProviderObservation, AIError>
readonly runTool: (call: ToolCall) => Effect.Effect<void, Permission.DeclinedError | QuestionTool.CancelledError>
readonly finishProvider: (stream: Exit.Exit<void, AIError>) => Effect.Effect<void>
readonly recoverOverflow: (settlement: Settlement) => Effect.Effect<boolean>
readonly settle: (settlement: Settlement) => Effect.Effect<Outcome, AIError | StepFailedError>
}
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
@@ -60,7 +79,7 @@ export const make = Effect.gen(function* () {
const snapshots = yield* Snapshot.Service
const toolOutput = yield* ToolOutput.Service
const attempt = Effect.fn("SessionStep.attempt")(function* (input: Input) {
const open = Effect.fn("SessionStep.open")(function* (input: Input) {
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(bus, {
sessionID: input.sessionID,
@@ -70,185 +89,197 @@ export const make = Effect.gen(function* () {
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
snapshot: startSnapshot,
})
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const executeTool = (call: ToolCall) => {
if (input.prepared.request.toolChoice?.type === "none")
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
return input.prepared.executeTool({
sessionID: input.sessionID,
agent: input.agent,
messageID: input.assistantMessageID,
call,
progress: (update) => publisher.progress(call.id, update),
})
}
// Provider and tool fibers retain per-source order without a shared writer queue.
// A local execution starts only after its Tool.Called publication completes.
const scope = yield* Scope.Scope
const providerScope = yield* Scope.fork(scope)
const pull = yield* llm
.stream(input.prepared.request, input.prepared.options)
.pipe(Stream.ensuring(publisher.flush()), Stream.toPull, Scope.provide(providerScope))
let buffered: ReadonlyArray<LLMEvent> = []
let offset = 0
let overflowFailure: ProviderErrorEvent | undefined
// Read to the end, not just the finish event, so the next request can reuse this response.
const providerStream = llm.stream(input.prepared.request, input.prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
const observeUntilBoundary = Effect.fnUntraced(function* (): Effect.fn.Return<ProviderObservation, AIError> {
while (true) {
const event = buffered[offset]
if (event) {
offset += 1
if (overflowFailure || publisher.hasProviderError()) continue
if (
LLMEvent.is.providerError(event) &&
isContextOverflowFailure(event) &&
!publisher.record().outputStarted
) {
overflowFailure = event
return
continue
}
yield* publisher.publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
toolRuns.push({
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(executeTool(event)).pipe(
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(publisher.flush()),
)
// Keep the final tool and Step events uninterruptible, even when the work itself is cancelled.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
const streamInterrupted = Exit.hasInterrupts(stream)
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (Exit.isFailure(joined)) yield* interruptTools
const tools = classifyToolExits(joined, toolRuns)
if (
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(input.recoverOverflow))
)
return Outcome.Compacted()
if (overflowFailure) yield* publisher.publish(overflowFailure)
const recorded = publisher.record()
const unknownFinish =
Exit.isSuccess(stream) && recorded.finish?.finish === "unknown"
? new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended with an unknown finish reason.",
classification: "incomplete-stream",
}),
})
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!recorded.outputStarted
)
return Outcome.RecoverFull()
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !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 })
// Keep the publisher's in-memory mark and durable write indivisible under cancellation.
yield* publisher.publish(event).pipe(Effect.uninterruptible)
if (event.type === "tool-call" && !event.providerExecuted)
return ProviderObservation.ToolCall({ call: event })
continue
}
if (llmError) yield* publisher.failAssistant(llmError)
const chunk = yield* pull.pipe(Pull.catchDone(() => Effect.succeed(undefined)))
if (!chunk) return ProviderObservation.ProviderEnd()
buffered = chunk
offset = 0
}
})
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
const toolFailure = interrupted
? TOOLS_INTERRUPTED
: tools.failure !== undefined
? toSessionError(Cause.squash(tools.failure))
: recorded.providerFailed
? TOOLS_INTERRUPTED
: undefined
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
const runTool = Effect.fnUntraced(function* (call: ToolCall) {
return yield* Effect.uninterruptibleMask((restore) => {
if (input.prepared.request.toolChoice?.type === "none")
return publisher
.failTool(call.id, { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" })
.pipe(Effect.asVoid)
return restore(
input.prepared.executeTool({
sessionID: input.sessionID,
agent: input.agent,
messageID: input.assistantMessageID,
call,
progress: (update) => publisher.progress(call.id, update),
}),
).pipe(
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(call.id, call.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(call.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
)
})
})
// All local fibers have joined; only provider-hosted results can still be missing.
if (llmError || (Exit.isSuccess(stream) && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
}
const finishProvider = Effect.fnUntraced(function* (stream: Exit.Exit<void, AIError>) {
yield* Scope.close(providerScope, stream)
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
}, Effect.uninterruptible)
const record = publisher.record()
if (record.finish || record.failure) {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? startSnapshot === snapshot
? []
: yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
const usage = record.finish
? { cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens), tokens: record.finish.tokens }
: undefined
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
if (record.finish && usage && !record.failure)
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* publisher.startAssistant(),
finish: record.finish.finish,
rawFinish: record.finish.rawFinish,
providerState: record.finish.providerState,
...usage,
snapshot,
files,
const recoverOverflow = (settlement: Settlement) => {
if (publisher.record().outputStarted) return Effect.succeed(false)
const failure = overflowFailure ?? Option.getOrUndefined(Exit.findErrorOption(settlement.stream))
return isContextOverflowFailure(failure) ? input.recoverOverflow : Effect.succeed(false)
}
const settle = Effect.fn("SessionStep.settle")(function* (settlement: Settlement) {
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(settlement.stream))
const streamInterrupted = Exit.hasInterrupts(settlement.stream)
const tools = classifyToolExits(settlement.tools)
if (overflowFailure) yield* publisher.publish(overflowFailure)
const recorded = publisher.record()
const unknownFinish =
Exit.isSuccess(settlement.stream) && recorded.finish?.finish === "unknown"
? new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended with an unknown finish reason.",
classification: "incomplete-stream",
}),
})
}
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!recorded.outputStarted
)
return Outcome.RecoverFull()
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
yield* publisher.startAssistant()
return Outcome.Retry({ cause: llmFailure, error: llmError })
}
if (llmError) yield* publisher.failAssistant(llmError)
// 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)) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return Outcome.Continue({ cause: llmFailure, error: llmError })
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
}),
)
}, Effect.scoped)
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
const toolFailure = interrupted
? TOOLS_INTERRUPTED
: tools.failure !== undefined
? toSessionError(Cause.squash(tools.failure))
: recorded.providerFailed
? TOOLS_INTERRUPTED
: undefined
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
return { attempt }
if (llmError || (Exit.isSuccess(settlement.stream) && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
}
const record = publisher.record()
if (record.finish || record.failure) {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? startSnapshot === snapshot
? []
: yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
const usage = record.finish
? {
cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens),
tokens: record.finish.tokens,
}
: undefined
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
if (record.finish && usage && !record.failure)
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* publisher.startAssistant(),
finish: record.finish.finish,
rawFinish: record.finish.rawFinish,
providerState: record.finish.providerState,
...usage,
snapshot,
files,
})
}
// 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)) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return Outcome.Continue({ cause: llmFailure, error: llmError })
if (Exit.isFailure(settlement.stream)) return yield* Effect.failCause(settlement.stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
})
}, Effect.uninterruptible)
return {
observeUntilBoundary,
runTool,
finishProvider,
recoverOverflow,
settle,
} satisfies Attempt
})
return { open }
})
const isInterruptedStream = (failure: AIError) => {
@@ -259,20 +290,19 @@ const isInterruptedStream = (failure: AIError) => {
/** Tool.Error settles in each fiber; only user declines remain in the typed error channel. */
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>>>,
runs: ReadonlyArray<{ readonly call: ToolCall }>,
runs: ReadonlyArray<{
readonly call: ToolCall
readonly exit: ToolExit
}>,
) => {
const exits = Exit.isSuccess(settled) ? settled.value : []
const declines = exits.flatMap((exit, index) =>
Exit.isFailure(exit)
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) ? [{ call: runs[index].call, reason: reason.error }] : [],
const declines = runs.flatMap((run) =>
Exit.isFailure(run.exit)
? run.exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) ? [{ call: run.call, reason: reason.error }] : [],
)
: [],
)
const causes = Exit.isFailure(settled)
? [settled.cause]
: exits.flatMap((exit) => (Exit.isFailure(exit) ? [exit.cause] : []))
const causes = runs.flatMap((run) => (Exit.isFailure(run.exit) ? [run.exit.cause] : []))
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
+1 -1
View File
@@ -287,7 +287,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
batches(ids.map((row) => row.id)),
(batch) =>
db
.select({ data: EventTable.data })
.select({ created: EventTable.created, data: EventTable.data })
.from(EventTable)
.where(
and(
+3 -6
View File
@@ -84,8 +84,7 @@ describe("Catalog", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
@@ -93,8 +92,7 @@ describe("Catalog", () => {
)
expect(yield* catalog.provider.available()).toEqual([])
const credentials = yield* Credential.Service
yield* credentials.create({
yield* (yield* Credential.Service).create({
integrationID,
value: Credential.Key.make({ type: "key", key: "secret" }),
})
@@ -114,8 +112,7 @@ describe("Catalog", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
@@ -15,14 +15,6 @@ const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
const teams = sqliteTable("teams", {
id: integer().primaryKey(),
name: text().notNull(),
})
const memberships = sqliteTable("memberships", {
user_id: integer().notNull(),
team_id: integer().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -171,41 +163,3 @@ test("supports returning and rejects empty update sets", async () => {
}),
)
})
test("supports function-valued update joins with runtime table columns", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Grace" })
.from(teams)
.innerJoin(memberships, (update) => eq(update.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Grace", "Core"],
})
}),
)
})
test("supports SQL-valued update joins", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Lin" })
.from(teams)
.innerJoin(memberships, eq(users.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Lin", "Core"],
})
}),
)
})
@@ -201,8 +201,7 @@ describe("layer node", () => {
Layer.provide(LayerNode.compile(result.hoisted)),
) as unknown as Layer.Layer<App>
const program = Effect.gen(function* () {
const app = yield* App
return yield* app.run
return yield* (yield* App).run
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["Alice"])
@@ -0,0 +1,415 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Scheduler } from "effect"
import { StateMachine } from "@opencode-ai/core/effect/state-machine"
import { it } from "../lib/effect"
describe("StateMachine", () => {
it.effect("runs invoked operations through pure transitions", () => {
type Event = { readonly _tag: "Completed"; readonly value: number }
type Operation = { readonly _tag: "Work" }
const definition = StateMachine.define<"running", Event, Operation, never, number>({
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
transition: (state, event) => {
expect(state).toBe("running")
expect(event._tag).toBe("InvocationExited")
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done(-1)
return StateMachine.done(event.exit.value.value)
},
})
return StateMachine.run(definition, () => Effect.succeed({ _tag: "Completed", value: 42 })).pipe(
Effect.map((output) => expect(output).toBe(42)),
)
})
it.effect("preserves the operation Cause", () => {
type Operation = { readonly _tag: "Work" }
const definition = StateMachine.define<"running", never, Operation, string, Cause.Cause<string>>({
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
transition: (_, event) => {
if (event._tag === "InvocationExited" && Exit.isFailure(event.exit)) return StateMachine.done(event.exit.cause)
throw new Error("Expected the invocation to fail")
},
})
return StateMachine.run(definition, () => Effect.fail("boom")).pipe(
Effect.map((cause) => {
expect(Option.getOrUndefined(Cause.findErrorOption(cause))).toBe("boom")
}),
)
})
it.effect("settles owned work before propagating interruption", () =>
Effect.gen(function* () {
const finalized = yield* Deferred.make<void>()
type State = "running" | "stopping"
type Event = { readonly _tag: "Cancel" }
type Operation = { readonly _tag: "Work" }
const definition = StateMachine.define<State, Event, Operation, never, "cancelled">({
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
interruption: { _tag: "Cancel" } as const,
transition: (state, event) => {
if (event._tag === "Input") {
expect(state).toBe("running")
return StateMachine.next("stopping" as const, StateMachine.stop("work"))
}
expect(state).toBe("stopping")
if (event._tag !== "InvocationExited") throw new Error("Expected the invocation to stop")
expect(Exit.hasInterrupts(event.exit)).toBe(true)
return StateMachine.done("cancelled" as const)
},
})
const machine = yield* StateMachine.run(definition, () =>
Effect.never.pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
).pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.yieldNow
yield* Fiber.interrupt(machine)
const exit = yield* Fiber.await(machine)
expect(Exit.hasInterrupts(exit)).toBe(true)
expect(yield* Deferred.isDone(finalized)).toBe(true)
}),
)
it.effect("runs cleanup invocations after interruption", () =>
Effect.gen(function* () {
const workStarted = yield* Deferred.make<void>()
const cleanupRan = yield* Deferred.make<void>()
type State = "running" | "stopping" | "cleaning"
type Event = { readonly _tag: "Cancel" } | { readonly _tag: "WorkDone" } | { readonly _tag: "CleanupDone" }
type Operation = { readonly _tag: "Work" } | { readonly _tag: "Cleanup" }
const definition = StateMachine.define<State, Event, Operation, never, void>({
initial: StateMachine.next("running", StateMachine.invoke("phase", { _tag: "Work" })),
interruption: { _tag: "Cancel" },
transition: (state, event) => {
if (event._tag === "Input")
return StateMachine.next("stopping", StateMachine.stopAndJoin("interruption", ["phase"]))
if (state === "stopping") {
if (event._tag !== "InvocationsStopped") throw new Error("Expected the aggregate stop result")
expect(event.id).toBe("interruption")
expect(event.exits).toMatchObject([{ id: "phase", operation: { _tag: "Work" } }])
expect(Exit.hasInterrupts(event.exits[0].exit)).toBe(true)
return StateMachine.next("cleaning", StateMachine.invoke("cleanup", { _tag: "Cleanup" }))
}
if (state === "cleaning") return StateMachine.done(undefined)
throw new Error("Unexpected state machine transition")
},
})
const machine = yield* StateMachine.run(definition, (operation) => {
if (operation._tag === "Cleanup")
return Deferred.succeed(cleanupRan, undefined).pipe(Effect.as({ _tag: "CleanupDone" } as const))
return Deferred.succeed(workStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.as({ _tag: "WorkDone" } as const),
)
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(workStarted)
yield* Fiber.interrupt(machine)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Deferred.isDone(cleanupRan)).toBe(true)
}),
)
it.effect("stops invocations together and joins cross-dependent finalizers", () =>
Effect.gen(function* () {
const started = { left: yield* Deferred.make<void>(), right: yield* Deferred.make<void>() }
const finalizing = { left: yield* Deferred.make<void>(), right: yield* Deferred.make<void>() }
const finalized = yield* Ref.make<ReadonlyArray<string>>([])
type State = "running" | "stopping" | "verifying"
type Event = "ready" | "verified"
type Operation = "left" | "right" | "trigger" | "verify"
const definition = StateMachine.define<State, Event, Operation, never, boolean>({
initial: StateMachine.next(
"running",
StateMachine.invoke<Operation>("left", "left"),
StateMachine.invoke<Operation>("right", "right"),
StateMachine.invoke<Operation>("trigger", "trigger"),
),
transition: (state, event) => {
if (event._tag === "InvocationExited" && event.operation === "trigger")
return StateMachine.next("stopping", StateMachine.stopAndJoin("workers", ["left", "right"]))
if (event._tag === "InvocationsStopped") {
expect(state).toBe("stopping")
expect(event.id).toBe("workers")
expect(event.exits).toMatchObject([
{ _tag: "InvocationExited", id: "left", generation: 1, operation: "left" },
{ _tag: "InvocationExited", id: "right", generation: 2, operation: "right" },
])
expect(event.exits.every((invocation) => Exit.hasInterrupts(invocation.exit))).toBe(true)
return StateMachine.next("verifying", StateMachine.invoke("verify", "verify"))
}
if (event._tag === "InvocationExited" && event.operation === "verify") {
expect(state).toBe("verifying")
expect(event.exit).toEqual(Exit.succeed("verified"))
return StateMachine.done(true)
}
throw new Error("Unexpected state machine transition")
},
})
const output = yield* StateMachine.run(definition, (operation) => {
if (operation === "trigger")
return Deferred.await(started.left).pipe(Effect.andThen(Deferred.await(started.right)), Effect.as("ready"))
if (operation === "verify")
return Ref.get(finalized).pipe(
Effect.map((value) => {
expect(value.toSorted()).toEqual(["left", "right"])
return "verified" as const
}),
)
return Deferred.succeed(started[operation], undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(finalizing[operation], undefined).pipe(
Effect.andThen(Deferred.await(finalizing[operation === "left" ? "right" : "left"])),
Effect.andThen(Ref.update(finalized, (value) => [...value, operation])),
),
),
)
})
expect(output).toBe(true)
}),
)
it.effect("aggregates queued and never-started exits once without affecting reused keys", () =>
Effect.gen(function* () {
const completed = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>()
const releaseCompleted = yield* Deferred.make<void>()
const gateStarted = yield* Deferred.make<void>()
const childStarted = yield* Deferred.make<void>()
type Event = "completed" | "triggered" | "replaced"
type Operation = "complete" | "gate" | "trigger" | "never-started" | "replacement"
type Seen = ReadonlyArray<StateMachine.RuntimeEvent<Event, Operation, never>>
const definition = StateMachine.define<Seen, Event, Operation, never, Seen>({
initial: StateMachine.next(
[],
StateMachine.invoke<Operation>("completed", "complete"),
StateMachine.invoke<Operation>("gate", "gate"),
StateMachine.invoke<Operation>("trigger", "trigger"),
),
transition: (state, event) => {
const seen = [...state, event]
if (event._tag === "InvocationExited" && event.operation === "trigger")
return StateMachine.next(
seen,
StateMachine.stop("gate"),
StateMachine.invoke<Operation>("child", "never-started"),
StateMachine.stopAndJoin("batch", ["completed", "gate", "child"]),
StateMachine.invoke<Operation>("completed", "replacement"),
StateMachine.invoke<Operation>("child", "replacement"),
)
return seen.length === 4 ? StateMachine.done(seen) : StateMachine.next(seen)
},
})
const seen = yield* StateMachine.run(definition, (operation) => {
if (operation === "complete")
return Effect.withFiber((fiber) => Deferred.succeed(completed, fiber)).pipe(
Effect.andThen(Deferred.await(releaseCompleted)),
Effect.as("completed"),
)
if (operation === "gate")
return Deferred.succeed(gateStarted, undefined).pipe(
Effect.andThen(Effect.never),
// Hold the command loop until the completed child's exit is queued.
Effect.ensuring(
Deferred.succeed(releaseCompleted, undefined).pipe(
Effect.andThen(Deferred.await(completed)),
Effect.flatMap(Fiber.await),
),
),
)
if (operation === "trigger")
return Deferred.await(completed).pipe(Effect.andThen(Deferred.await(gateStarted)), Effect.as("triggered"))
if (operation === "never-started")
return Deferred.succeed(childStarted, undefined).pipe(Effect.andThen(Effect.never))
return Effect.succeed("replaced")
}).pipe(
// Keep the adjacent invoke/stop commands in one scheduler slice.
Effect.provideService(Scheduler.PreventSchedulerYield, true),
)
expect(seen.map((event) => (event._tag === "InvocationExited" ? event.operation : event._tag))).toEqual([
"trigger",
"InvocationsStopped",
"replacement",
"replacement",
])
const stopped = seen[1]
if (stopped._tag !== "InvocationsStopped") throw new Error("Expected the aggregate stop result")
expect(stopped.id).toBe("batch")
expect(stopped.exits).toMatchObject([
{
_tag: "InvocationExited",
id: "completed",
generation: 1,
operation: "complete",
exit: Exit.succeed("completed"),
},
{ _tag: "InvocationExited", id: "gate", generation: 2, operation: "gate" },
{ _tag: "InvocationExited", id: "child", generation: 4, operation: "never-started" },
])
expect(stopped.exits.slice(1).every((invocation) => Exit.hasInterrupts(invocation.exit))).toBe(true)
expect(seen.slice(2)).toMatchObject([
{ _tag: "InvocationExited", id: "completed", generation: 5, exit: Exit.succeed("replaced") },
{ _tag: "InvocationExited", id: "child", generation: 6, exit: Exit.succeed("replaced") },
])
expect(yield* Deferred.isDone(childStarted)).toBe(false)
}),
)
it.effect("emits an empty aggregate for an empty stop batch", () => {
const definition = StateMachine.define<"stopping", never, never, never, boolean>({
initial: StateMachine.next("stopping", StateMachine.stopAndJoin("empty", [])),
transition: (_, event) => {
expect(event).toEqual({ _tag: "InvocationsStopped", id: "empty", exits: [] })
return StateMachine.done(true)
},
})
return StateMachine.run(definition, () => Effect.die("Unexpected operation")).pipe(
Effect.map((output) => expect(output).toBe(true)),
)
})
it.effect("awaits a never-started finalizer without interrupting it", () =>
Effect.gen(function* () {
const finalized = yield* Ref.make(0)
type Operation = "work" | "finalize"
const definition = StateMachine.define<"stopping", "finalized", Operation, never, boolean>({
initial: StateMachine.next(
"stopping",
StateMachine.invoke<Operation>("work", "work"),
StateMachine.invoke<Operation>("finalizer", "finalize"),
StateMachine.stopAndJoin("batch", ["work"], ["finalizer"]),
),
transition: (_, event) => {
if (event._tag !== "InvocationsStopped") throw new Error("Expected only the joined batch")
expect(event.exits).toHaveLength(2)
expect(event.exits[0].id).toBe("work")
expect(Exit.hasInterrupts(event.exits[0].exit)).toBe(true)
expect(event.exits[1]).toMatchObject({ id: "finalizer", exit: Exit.succeed("finalized") })
return StateMachine.done(true)
},
})
expect(
yield* StateMachine.run(definition, (operation) =>
operation === "work"
? Effect.never
: Ref.update(finalized, (count) => count + 1).pipe(Effect.as("finalized" as const)),
).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true)),
).toBe(true)
expect(yield* Ref.get(finalized)).toBe(1)
}),
)
it.effect("defects when a stop batch contains an unknown invocation", () =>
Effect.gen(function* () {
const definition = StateMachine.define<"stopping", never, "work", never, never>({
initial: StateMachine.next(
"stopping",
StateMachine.invoke("known", "work"),
StateMachine.stopAndJoin("batch", ["known", "unknown"]),
),
transition: () => {
throw new Error("Unexpected state machine transition")
},
})
const exit = yield* StateMachine.run(definition, () => Effect.never).pipe(Effect.exit)
if (Exit.isSuccess(exit)) throw new Error("Expected an unknown invocation defect")
expect(Cause.hasDies(exit.cause)).toBe(true)
expect(Cause.prettyErrors(exit.cause).map((error) => error.message)).toEqual([
"Unknown state machine invocation in StopAndJoin",
])
}),
)
it.effect("observes an individual exit when a deferred child is stopped before starting", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const definition = StateMachine.define<"stopping", never, "work", never, boolean>({
initial: StateMachine.next("stopping", StateMachine.invoke("work", "work"), StateMachine.stop("work")),
transition: (_, event) => {
if (event._tag !== "InvocationExited") throw new Error("Expected the invocation to stop")
expect(event.id).toBe("work")
expect(Exit.hasInterrupts(event.exit)).toBe(true)
return StateMachine.done(true)
},
})
const output = yield* StateMachine.run(definition, () =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true))
expect(output).toBe(true)
expect(yield* Deferred.isDone(started)).toBe(false)
}),
)
it.effect("waits for replaced invocation cleanup and ignores its stale exit", () =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseTrigger = yield* Deferred.make<void>()
const events = yield* Ref.make<ReadonlyArray<string>>([])
type State = "first" | "second"
type Event = { readonly _tag: "Triggered" } | { readonly _tag: "SecondDone" }
type Operation = { readonly _tag: "First" } | { readonly _tag: "Trigger" } | { readonly _tag: "Second" }
const definition = StateMachine.define<State, Event, Operation, never, string>({
initial: StateMachine.next(
"first",
StateMachine.invoke<Operation>("work", { _tag: "First" }),
StateMachine.invoke<Operation>("trigger", { _tag: "Trigger" }),
),
transition: (state, event) => {
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done("unexpected")
if (event.operation._tag === "Trigger") {
return StateMachine.next("second" as const, StateMachine.invoke("work", { _tag: "Second" } as const))
}
if (state === "second") return StateMachine.done(event.exit.value._tag)
return StateMachine.next(state)
},
})
const output = yield* StateMachine.run(definition, (operation) => {
if (operation._tag === "Trigger")
return Deferred.await(releaseTrigger).pipe(Effect.as({ _tag: "Triggered" } as const))
if (operation._tag === "Second") {
return Ref.update(events, (value) => [...value, "second started"]).pipe(
Effect.as({ _tag: "SecondDone" } as const),
)
}
return Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Ref.update(events, (value) => [...value, "first finalized"])),
)
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(firstStarted)
yield* Deferred.succeed(releaseTrigger, undefined)
expect(yield* Fiber.join(output)).toBe("SecondDone")
expect(yield* Ref.get(events)).toEqual(["first finalized", "second started"])
}),
)
it.effect("does not start the next invocation when interruption is pending at the transition boundary", () =>
Effect.gen(function* () {
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
type State = "first" | "second"
type Event = { readonly _tag: "FirstDone" } | { readonly _tag: "SecondDone" }
type Operation = { readonly _tag: "First" } | { readonly _tag: "Second" }
let machine: Fiber.Fiber<string> | undefined
const definition = StateMachine.define<State, Event, Operation, never, string>({
initial: StateMachine.next("first", StateMachine.invoke("work", { _tag: "First" })),
transition: (state, event) => {
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done("unexpected")
if (state === "second") return StateMachine.done("completed")
machine?.interruptUnsafe(123)
return StateMachine.next("second", StateMachine.invoke("work", { _tag: "Second" }))
},
})
machine = yield* StateMachine.run(definition, (operation) =>
operation._tag === "First"
? Deferred.await(releaseFirst).pipe(Effect.as({ _tag: "FirstDone" } as const))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.as({ _tag: "SecondDone" } as const)),
).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.succeed(releaseFirst, undefined)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
}),
)
})
@@ -244,25 +244,14 @@ describe("doStream", () => {
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
// In this fixture, reasoning_opaque comes AFTER content has started (in chunk 4)
// So it arrives too late to be attached to reasoning-end. It should still be
// captured on the completed text part and the finish event.
// So it arrives too late to be attached to reasoning-end. But it should still
// be captured and included in the finish event's providerMetadata.
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
})
const textEnd = parts.find((p) => p.type === "text-end")
expect(textEnd).toEqual({
type: "text-end",
id: "txt-0",
providerMetadata: {
copilot: {
reasoningOpaque: "/PMlTqxqSJZnUBDHgnnJKLVI4eZQ",
},
},
})
// reasoning_opaque should be in the finish event's providerMetadata
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
@@ -316,17 +305,6 @@ describe("doStream", () => {
},
})
const textEnd = parts.find((p) => p.type === "text-end")
expect(textEnd).toEqual({
type: "text-end",
id: "txt-0",
providerMetadata: {
copilot: {
reasoningOpaque: "ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx",
},
},
})
// Check text deltas
const textDeltas = parts.filter((p) => p.type === "text-delta")
expect(textDeltas).toHaveLength(2)
@@ -51,8 +51,7 @@ describe("FileSystem", () => {
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list()
const entries = yield* (yield* FileSystem.Service).list()
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make("src" + path.sep), type: "directory" },
{ path: RelativePath.make("README.md"), type: "file" },
@@ -104,8 +103,9 @@ describe("FileSystem", () => {
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
const result = yield* (yield* FileSystem.Service)
.read({ path: RelativePath.make("../outside.txt") })
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(directory)),
),
+2 -12
View File
@@ -13,7 +13,6 @@ import {
Hash,
Layer,
LayerMap,
Option,
RcMap,
Schema,
Stream,
@@ -518,8 +517,7 @@ describe("LocationServiceMap", () => {
)
const plugins = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* (yield* PluginSupervisor.Service).flush
return yield* plugins.list()
}).pipe(
Effect.scoped,
@@ -676,21 +674,14 @@ describe("LocationServiceMap", () => {
expect(Equal.equals(absent, present)).toBe(false)
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first)
expect(Option.getOrThrow(yield* locations.contextEffectOption(absent))).toBe(first)
expect(Option.getOrThrow(yield* locations.contextEffectOption(present))).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
expect(yield* locations.contextEffectOption(present)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
}),
),
@@ -943,8 +934,7 @@ describe("LocationServiceMap", () => {
})
yield* plugins.activate([{ ...reviewer, version: "1" }])
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",
mode: "subagent",
})
+12 -24
View File
@@ -43,8 +43,7 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -59,8 +58,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -72,8 +70,7 @@ describe("LocationMutation", () => {
it.live("requires external-directory authorization for a relative lexical escape", () =>
withTmp((directory) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "../outside.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -120,8 +117,7 @@ describe("LocationMutation", () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
@@ -141,8 +137,7 @@ describe("LocationMutation", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
const mutation = yield* LocationMutation.Service
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -154,8 +149,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -170,8 +164,7 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -192,8 +185,7 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)),
@@ -205,8 +197,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: outside, kind: "file" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -221,8 +212,7 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -264,8 +254,7 @@ describe("LocationMutation", () => {
it.live("resolves a tilde path as an external home target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
@@ -281,8 +270,7 @@ describe("LocationMutation", () => {
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
+3 -48
View File
@@ -16,7 +16,6 @@ import { emptyMcpLayer } from "../fixture/mcp"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
import PROMPT_INITIALIZE from "../../src/plugin/command/initialize.txt"
import PROMPT_REVIEW from "../../src/plugin/command/review.txt"
const directory = AbsolutePath.make("/repo/packages/app")
@@ -41,11 +40,7 @@ describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* Command.Service
const prompts: {
text: string
files?: readonly { readonly uri: string }[]
delivery?: "steer" | "queue"
}[] = []
const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = []
yield* CommandPlugin.Plugin.effect(
host({
command: {
@@ -56,7 +51,7 @@ describe("CommandPlugin.Plugin", () => {
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
prompts.push({ text: input.text, files: input.files })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
@@ -91,50 +86,10 @@ describe("CommandPlugin.Plugin", () => {
delivery: "queue",
},
})
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: " branch $& $$ $` $' " },
delivery: "steer",
},
})
yield* command.execute({
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "" },
delivery: "steer",
},
})
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: " " },
delivery: "steer",
},
})
expect(prompts).toEqual([
{
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
text: expect.stringContaining("extra context"),
files: [{ uri: "file:///tmp/context.md" }],
delivery: "queue",
},
{
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
files: undefined,
delivery: "steer",
},
{
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
{
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
])
}),
@@ -306,34 +306,6 @@ describe("AppProcess", () => {
}),
)
it.live(
"includes stderr in output while retaining capped failure diagnostics",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const lines: string[] = []
const exit = yield* Effect.exit(
svc
.runStream(
cmd(
"-e",
"console.log('stdout-line'); console.error('stderr-line'); console.error('diagnostic-tail'); process.exit(2)",
),
{ includeStderr: true, maxErrorBytes: 20, okExitCodes: [0] },
)
.pipe(Stream.runForEach((line) => Effect.sync(() => lines.push(line)))),
)
expect(lines.toSorted()).toEqual(["diagnostic-tail", "stderr-line", "stdout-line"])
expect(Exit.isFailure(exit)).toBe(true)
if (!Exit.isFailure(exit)) return
const reason = exit.cause.reasons[0]
expect(reason?._tag).toBe("Fail")
if (!reason || reason._tag !== "Fail") return
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect(reason.error.stderr).toBe("stderr-line\ndiagnost")
}),
)
it.live(
"without okExitCodes, never fails on exit code",
Effect.gen(function* () {
+2 -4
View File
@@ -25,8 +25,7 @@ describe("RepositoryCache", () => {
await fs.writeFile(path.join(localPath, "stale.txt"), "stale")
})
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* exists(path.join(localPath, "stale.txt"))).toBe(false)
@@ -95,8 +94,7 @@ describe("RepositoryCache", () => {
Effect.gen(function* () {
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
+48 -23
View File
@@ -582,6 +582,13 @@ const scenario = (
}),
)
const nextRetryScheduled = (s: Scenario) =>
s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const providerUnavailable = () =>
new AIError({
reason: new TransportError({
@@ -4357,8 +4364,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(providerUnavailable()))
yield* s.llm.push(TestLLM.text("Recovered", "retry-success"))
const scheduled = yield* nextRetryScheduled(s)
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")
@@ -4379,11 +4387,7 @@ describe("SessionRunnerLLM", () => {
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"))
const scheduled = yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Fiber.join(scheduled)
yield* s.session.interrupt(sessionID)
@@ -4425,8 +4429,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(incompleteStream()))
yield* s.llm.push(TestLLM.text("Recovered", "incomplete-stream-success"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4446,8 +4451,9 @@ describe("SessionRunnerLLM", () => {
])
yield* s.llm.push(TestLLM.text("Recovered", "unknown-finish-success"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4464,8 +4470,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(rateLimited(5_000)))
yield* s.llm.push(TestLLM.text("Recovered", "retry-after-success"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
@@ -4478,8 +4485,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(rateLimited(3_600_000)))
yield* s.llm.push(TestLLM.text("Recovered", "retry-cap-success"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("899999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
@@ -4500,8 +4508,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text(" continuation", "continued-text"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4549,8 +4558,9 @@ describe("SessionRunnerLLM", () => {
])
yield* s.llm.push(TestLLM.text(" continuation", "unknown-continuation"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4579,8 +4589,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text(" continuation", "rate-limit-continuation"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
@@ -4617,8 +4628,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text(" continuation", "unknown-failure-continuation"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4647,8 +4659,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text("Recovered", "reasoning-recovery"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4689,8 +4702,9 @@ describe("SessionRunnerLLM", () => {
)
yield* s.llm.push(TestLLM.text("Recovered", "reasoning-transport-recovery"))
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -4831,11 +4845,16 @@ describe("SessionRunnerLLM", () => {
),
)
const scheduled = yield* Queue.unbounded<void>()
yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runForEach(() => Queue.offer(scheduled, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
yield* Queue.take(scheduled)
yield* TestClock.adjust(delay)
yield* s.llm.wait(index + 2)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(5)
@@ -4849,11 +4868,16 @@ describe("SessionRunnerLLM", () => {
const failure = providerUnavailable()
yield* s.llm.always(Stream.fail(failure))
const scheduled = yield* Queue.unbounded<void>()
yield* s.bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runForEach(() => Queue.offer(scheduled, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
yield* Queue.take(scheduled)
yield* TestClock.adjust(delay)
yield* s.llm.wait(index + 2)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(5)
@@ -4898,8 +4922,9 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(Stream.fail(failure))
yield* s.llm.push(TestLLM.tool("call-after-retry", "echo", { text: "recovered" }), TestLLM.stop())
const scheduled = yield* nextRetryScheduled(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* Fiber.join(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
@@ -0,0 +1,527 @@
import { describe, expect, test } from "bun:test"
import { AIError, TransportError, type LLMEvent } from "@opencode-ai/ai"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStep } from "@opencode-ai/core/session/runner/step"
import { SessionStepMachine } from "@opencode-ai/core/session/runner/step-machine"
import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scheduler } from "effect"
import { it } from "./lib/effect"
const firstID = SessionMessage.ID.make("msg_first")
const failure = new AIError({
reason: new TransportError({ message: "Provider unavailable", transport: "http", operation: "request" }),
})
const error = { type: "provider.transport", message: "Provider unavailable" } as const
describe("SessionStepMachine", () => {
it.effect("completes a logical Step", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make<ReadonlyArray<SessionStepMachine.Context>>([])
const result = yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(attempts, (values) => [...values, context]).pipe(
Effect.as(
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true })),
}),
),
),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
expect(result).toBe(true)
expect(yield* Ref.get(attempts)).toEqual([
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
])
}),
)
it.effect("pulls, publishes, and runs a local tool before settlement", () =>
Effect.gen(function* () {
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const call = { type: "tool-call", id: "call_1", name: "lookup", input: {} } satisfies Extract<
LLMEvent,
{ type: "tool-call" }
>
const attempt = makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), {
events: [call],
operations,
})
yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
const observed = yield* Ref.get(operations)
expect(observed.indexOf("publish:tool-call")).toBeLessThan(observed.indexOf("tool:call_1"))
expect(observed.at(-1)).toBe("settle")
}),
)
it.effect("retries transparently with the same assistant", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.Retry({ cause: failure, error }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const result = yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(operations, (values) => [...values, `attempt:${context.assistantMessageID}`]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false })),
}),
),
),
retry: (context) => Ref.update(operations, (values) => [...values, `retry:${context.assistantMessageID}`]),
publishSynthetic: Effect.void,
})
expect(result).toBe(false)
expect(yield* Ref.get(operations)).toEqual([`attempt:${firstID}`, `retry:${firstID}`, `attempt:${firstID}`])
}),
)
it.effect("continues partial output only after retry and synthetic publication", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.Continue({ cause: failure, error }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const operations = yield* Ref.make<ReadonlyArray<string>>([])
yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(operations, (values) => [...values, `attempt:${context.assistantMessageID}`]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false })),
}),
),
),
retry: () => Ref.update(operations, (values) => [...values, "retry"]),
publishSynthetic: Ref.update(operations, (values) => [...values, "synthetic"]),
})
const observed = yield* Ref.get(operations)
expect(observed.slice(0, 3)).toEqual([`attempt:${firstID}`, "retry", "synthetic"])
expect(observed.at(3)).toStartWith("attempt:msg_")
expect(observed.at(3)).not.toBe(`attempt:${firstID}`)
}),
)
it.effect("tracks independent recovery allowances", () =>
Effect.gen(function* () {
const outcomes: Array<SessionStep.Outcome> = [
SessionStep.Outcome.RecoverFull(),
SessionStep.Outcome.Completed({ needsContinuation: false }),
SessionStep.Outcome.Completed({ needsContinuation: false }),
]
const recoveries = [false, true, false]
const attempts = yield* Ref.make<ReadonlyArray<SessionStepMachine.Context>>([])
yield* SessionStepMachine.run(firstID, {
prepare: (context) =>
Ref.update(attempts, (values) => [...values, context]).pipe(
Effect.map(() =>
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(outcomes.shift() ?? SessionStep.Outcome.Completed({ needsContinuation: false }), {
recoverOverflow: recoveries.shift(),
}),
}),
),
),
retry: () => Effect.void,
publishSynthetic: Effect.void,
})
const observed = yield* Ref.get(attempts)
expect(observed.slice(0, 2)).toEqual([
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
{ assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: false },
])
expect(observed.at(2)).toMatchObject({ recoverOverflow: false, recoverContinuation: false })
expect(observed.at(2)?.assistantMessageID).not.toBe(firstID)
}),
)
it.effect("does not begin another attempt when retry is interrupted", () =>
Effect.gen(function* () {
const retryStarted = yield* Deferred.make<void>()
const retryFinalized = yield* Deferred.make<void>()
const attempts = yield* Ref.make(0)
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () =>
Ref.update(attempts, (value) => value + 1).pipe(
Effect.as(
SessionStepMachine.Preparation.Ready({
attempt: makeAttempt(SessionStep.Outcome.Retry({ cause: failure, error })),
}),
),
),
retry: () =>
Deferred.succeed(retryStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Deferred.succeed(retryFinalized, undefined)),
),
publishSynthetic: Effect.void,
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(retryStarted)
yield* Fiber.interrupt(machine)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Deferred.isDone(retryFinalized)).toBe(true)
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
for (const outcome of [
SessionStep.Outcome.Completed({ needsContinuation: true }),
SessionStep.Outcome.Retry({ cause: failure, error }),
SessionStep.Outcome.Continue({ cause: failure, error }),
SessionStep.Outcome.RecoverFull(),
]) {
it.effect(`cancellation during settlement prevents ${outcome._tag} from starting more work`, () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = {
...makeAttempt(outcome),
settle: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Ref.update(operations, (values) => [...values, "settled"])),
Effect.as(outcome),
Effect.uninterruptible,
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () =>
Ref.update(operations, (values) => [...values, "prepare"]).pipe(
Effect.as(SessionStepMachine.Preparation.Ready({ attempt })),
),
retry: () => Ref.update(operations, (values) => [...values, "retry"]),
publishSynthetic: Ref.update(operations, (values) => [...values, "synthetic"]),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const interrupted = yield* Fiber.interrupt(machine).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["prepare", "settled"])
}),
)
}
it.effect("cancels provider and tools together, then closes and settles once", () =>
Effect.gen(function* () {
const providerStarted = yield* Deferred.make<void>()
const providerStopped = yield* Deferred.make<void>()
const toolStarted = yield* Deferred.make<void>()
const toolStopped = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const calls = [{ type: "tool-call", id: "call_parallel", name: "lookup", input: {} }] as const
const pending = [...calls]
const attempt: SessionStep.Attempt = {
...makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), { operations }),
observeUntilBoundary: () =>
Effect.suspend(() => {
const call = pending.shift()
if (call) return Effect.succeed(SessionStep.ProviderObservation.ToolCall({ call }))
return Deferred.succeed(providerStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(providerStopped, undefined).pipe(Effect.andThen(Deferred.await(toolStopped))),
),
)
}),
runTool: () =>
Deferred.succeed(toolStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(toolStopped, undefined).pipe(Effect.andThen(Deferred.await(providerStopped))),
),
),
settle: (settlement) =>
Effect.sync(() => {
expect(Exit.hasInterrupts(settlement.stream)).toBe(true)
expect(settlement.tools).toHaveLength(1)
expect(settlement.tools[0]?.call).toEqual(calls[0])
expect(settlement.tools.every((tool) => Exit.hasInterrupts(tool.exit))).toBe(true)
}).pipe(
Effect.andThen(Ref.update(operations, (values) => [...values, "settle"])),
Effect.as(SessionStep.Outcome.Completed({ needsContinuation: false })),
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(providerStarted)
yield* Deferred.await(toolStarted)
yield* Fiber.interrupt(machine)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["finish-provider", "settle"])
}),
)
it.effect("does not finalize the provider twice when cancellation races with finalization", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = {
...makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false }), { operations }),
finishProvider: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Ref.update(operations, (values) => [...values, "finish-provider"])),
Effect.uninterruptible,
),
}
const machine = yield* SessionStepMachine.run(firstID, {
prepare: () => Effect.succeed(SessionStepMachine.Preparation.Ready({ attempt })),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const interrupted = yield* Fiber.interrupt(machine).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["read:end", "finish-provider", "settle"])
}),
)
test("cancellation awaits provider finalization and stops pending tools before settling", () => {
const definition = SessionStepMachine.definition<never, never>(firstID)
const cause = Cause.interrupt(123)
const call = { type: "tool-call", id: "call_pending", name: "lookup", input: {} } as const
const completed = { ...call, id: "call_completed" }
const state = SessionStepMachine.State.FinalizingProvider({
active: {
context: { assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: false })),
tools: new Map([
[completed.id, { call: completed, exit: Exit.succeed(undefined) }],
[call.id, { call }],
]),
},
stream: Exit.succeed(undefined),
})
const stopping = definition.transition(state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
})
if (stopping._tag !== "Continue") throw new Error("Expected cancellation to await owned invocations")
expect(stopping.state).toEqual({ _tag: "Stopping", from: state, cause })
expect(stopping.commands).toEqual([
{ _tag: "StopAndJoin", id: "step", ids: ["tool:call_pending"], waitFor: ["provider"] },
])
expect(
definition.transition(stopping.state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
}),
).toEqual({ _tag: "Continue", state: stopping.state, commands: [] })
const settling = definition.transition(stopping.state, {
_tag: "InvocationsStopped",
id: "step",
exits: [
{
_tag: "InvocationExited",
id: "tool:call_pending",
generation: 1,
operation: SessionStepMachine.Operation.RunTool({ attempt: state.active.attempt, call }),
exit: Exit.interrupt(456),
},
{
_tag: "InvocationExited",
id: "provider",
generation: 2,
operation: SessionStepMachine.Operation.FinishProvider({
attempt: state.active.attempt,
stream: state.stream,
}),
exit: Exit.succeed(SessionStepMachine.Event.ProviderFinished({ exit: Exit.succeed(undefined) })),
},
],
})
if (settling._tag !== "Continue") throw new Error("Expected settlement after the joined batch")
expect(settling.state).toMatchObject({ _tag: "SettlingAttempt", stopping: cause })
expect(settling.commands).toEqual([
{
_tag: "Invoke",
id: "settlement",
operation: {
_tag: "SettleAttempt",
attempt: state.active.attempt,
settlement: {
stream: state.stream,
tools: [
{ call: completed, exit: Exit.succeed(undefined) },
{ call, exit: Exit.interrupt(456) },
],
},
},
},
])
})
for (const fixture of [
{ name: "never-started", exit: Exit.interrupt(456), replaced: false },
{
name: "queued false",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.succeed(false) })),
replaced: false,
},
{
name: "queued failure",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.die("Recovery failed") })),
replaced: false,
},
{
name: "queued true",
exit: Exit.succeed(SessionStepMachine.Event.OverflowRecovered({ exit: Exit.succeed(true) })),
replaced: true,
},
] as const) {
test(`cancellation reconciles ${fixture.name} overflow recovery before deciding settlement`, () => {
const definition = SessionStepMachine.definition<never, never>(firstID)
const cause = Cause.interrupt(123)
const state = SessionStepMachine.State.RecoveringOverflow({
active: {
context: { assistantMessageID: firstID, recoverOverflow: true, recoverContinuation: true },
attempt: makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true })),
tools: new Map(),
},
stream: Exit.succeed(undefined),
})
const stopping = definition.transition(state, {
_tag: "Input",
input: SessionStepMachine.Event.CancelRequested(),
cause,
})
if (stopping._tag !== "Continue") throw new Error("Expected cancellation to await recovery")
expect(stopping.state).toEqual({ _tag: "Stopping", from: state, cause })
expect(stopping.commands).toEqual([{ _tag: "StopAndJoin", id: "step", ids: ["compaction"], waitFor: [] }])
const settled = definition.transition(stopping.state, {
_tag: "InvocationsStopped",
id: "step",
exits: [
{
_tag: "InvocationExited",
id: "compaction",
generation: 1,
operation: SessionStepMachine.Operation.RecoverOverflow({
attempt: state.active.attempt,
settlement: { stream: state.stream, tools: [] },
}),
exit: fixture.exit,
},
],
})
if (fixture.replaced) {
expect(settled).toEqual({ _tag: "Done", output: Exit.failCause(cause) })
return
}
if (settled._tag !== "Continue") throw new Error("Expected the unreplaced attempt to settle")
expect(settled.state).toEqual({ _tag: "SettlingAttempt", active: state.active, stopping: cause })
expect(settled.commands).toEqual([
{
_tag: "Invoke",
id: "settlement",
operation: {
_tag: "SettleAttempt",
attempt: state.active.attempt,
settlement: { stream: Exit.failCause(cause), tools: [] },
},
},
])
const command = settled.commands[0]
if (command?._tag !== "Invoke") throw new Error("Expected a settlement invocation")
expect(
definition.transition(settled.state, {
_tag: "InvocationExited",
id: command.id,
generation: 2,
operation: command.operation,
exit: Exit.succeed(
SessionStepMachine.Event.AttemptSettled({
exit: Exit.succeed(SessionStep.Outcome.Completed({ needsContinuation: true })),
}),
),
}),
).toEqual({ _tag: "Done", output: Exit.failCause(cause) })
})
}
for (const target of ["finishProvider", "recoverOverflow"] as const) {
it.effect(`settles once when cancellation precedes ${target} execution`, () =>
Effect.gen(function* () {
const operations = yield* Ref.make<ReadonlyArray<string>>([])
const attempt = makeAttempt(SessionStep.Outcome.Completed({ needsContinuation: true }), { operations })
const machine = yield* Effect.withFiber((fiber) =>
SessionStepMachine.run(firstID, {
prepare: () =>
Effect.succeed(
SessionStepMachine.Preparation.Ready({
attempt: {
...attempt,
// Interrupt during construction, before the deferred invocation starts.
finishProvider: (stream) => {
if (target === "finishProvider") fiber.interruptUnsafe(123)
return attempt.finishProvider(stream)
},
recoverOverflow: (settlement) => {
if (target === "recoverOverflow") fiber.interruptUnsafe(123)
return attempt.recoverOverflow(settlement)
},
},
}),
),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}),
).pipe(
Effect.provideService(Scheduler.PreventSchedulerYield, true),
Effect.forkChild({ startImmediately: true }),
)
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
expect(yield* Ref.get(operations)).toEqual(["read:end", "finish-provider", "settle"])
}),
)
}
})
function makeAttempt(
outcome: SessionStep.Outcome,
options?: {
readonly events?: ReadonlyArray<LLMEvent>
readonly operations?: Ref.Ref<ReadonlyArray<string>>
readonly recoverOverflow?: boolean
},
): SessionStep.Attempt {
const events = [...(options?.events ?? [])]
const log = (value: string) =>
options?.operations ? Ref.update(options.operations, (values) => [...values, value]) : Effect.void
return {
observeUntilBoundary: () =>
Effect.gen(function* () {
const event = events.shift()
yield* log(event ? `read:${event.type}` : "read:end")
if (!event) return SessionStep.ProviderObservation.ProviderEnd()
yield* log(`publish:${event.type}`)
if (event.type !== "tool-call") return SessionStep.ProviderObservation.ProviderEnd()
return SessionStep.ProviderObservation.ToolCall({ call: event })
}),
runTool: (call) => log(`tool:${call.id}`),
finishProvider: () => log("finish-provider"),
recoverOverflow: () => Effect.succeed(options?.recoverOverflow ?? false),
settle: () => log("settle").pipe(Effect.as(outcome)),
}
}
+309 -72
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel, LLM, LLMEvent } from "@opencode-ai/ai"
import { AIError, LanguageModel, LLM, LLMEvent, TransportError } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Agent } from "@opencode-ai/core/agent"
@@ -11,17 +11,19 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStep } from "@opencode-ai/core/session/runner/step"
import { SessionStepMachine } from "@opencode-ai/core/session/runner/step-machine"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { asc, eq } from "drizzle-orm"
import { Effect, Exit, Layer } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
@@ -40,49 +42,21 @@ for (const fixture of [
] as const) {
it.effect(`settles ${fixture.finish} with tool choice ${fixture.toolChoice ?? "default"}`, () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const llm = yield* TestLLM.Test
const sessionID = Session.ID.create()
const assistantMessageID = SessionMessage.ID.create()
const start = Snapshot.ID.make("before")
const end = Snapshot.ID.make("after")
const files = [RelativePath.make("changed.ts")]
let captures = 0
let executions = 0
const steps = yield* SessionStep.make.pipe(
Effect.provide(
Layer.mock(Snapshot.Service)({
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
files: (input) => {
expect(input).toEqual({ from: start, to: end })
return Effect.succeed(files)
},
}),
),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
.run()
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 100_000, output: 1_000 },
cost: [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
},
],
const s = yield* setup({
snapshot: {
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
files: (input) => {
expect(input).toEqual({ from: start, to: end })
return Effect.succeed(files)
},
},
)
yield* llm.push(
})
yield* s.llm.push(
TestLLM.complete(
{
reason: { normalized: fixture.finish },
@@ -98,52 +72,33 @@ for (const fixture of [
LLMEvent.toolCall({ id: "call-test", name: "test", input: {} }),
),
)
const result = yield* steps
.attempt({
sessionID,
assistantMessageID,
agent: Agent.defaultID,
model,
prepared: {
request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }),
options: {},
const result = yield* SessionStepMachine.run(s.assistantMessageID, {
prepare: (context) =>
s.prepare(context, {
toolChoice: fixture.toolChoice,
executeTool: () =>
Effect.sync(() => {
executions++
return { content: "Completed tool" }
}),
},
recoverContinuation: true,
recoverOverflow: Effect.succeed(false),
})
.pipe(Effect.exit)
}),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.exit)
expect(Exit.isSuccess(result)).toBe(fixture.finish === "stop")
expect(executions).toBe(fixture.toolChoice === "none" ? 0 : 1)
if (Exit.isSuccess(result))
expect(result.value).toEqual(
SessionStep.Outcome.Completed({ needsContinuation: fixture.toolChoice !== "none" }),
)
expect(yield* llm.requests()).toHaveLength(1)
if (Exit.isSuccess(result)) expect(result.value).toBe(fixture.toolChoice !== "none")
expect(yield* s.llm.requests()).toHaveLength(1)
expect(captures).toBe(2)
const message = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, assistantMessageID))
.get()
expect(message?.data).toMatchObject({
const message = yield* s.message
expect(message).toMatchObject({
finish: fixture.finish,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
snapshot: { start, end, files },
content: [{ type: "tool", state: { status: fixture.toolChoice === "none" ? "error" : "completed" } }],
})
expect(message?.data).toHaveProperty("cost", expect.closeTo(0.0000233, 10))
const events = yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
const types = events.map((event) => event.type)
expect(message).toHaveProperty("cost", expect.closeTo(0.0000233, 10))
const types = yield* s.events
const terminal = fixture.finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
expect(types.filter((type) => type === terminal)).toHaveLength(1)
expect(
@@ -152,3 +107,285 @@ for (const fixture of [
}),
)
}
it.effect("closes provider stream resources before the next physical retry", () =>
Effect.gen(function* () {
const s = yield* setup()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupRelease = yield* Deferred.make<void>()
const operations: string[] = []
yield* s.llm.push(
Stream.unwrap(
Effect.acquireRelease(
Effect.sync(() => operations.push("acquire")),
() =>
Deferred.succeed(cleanupStarted, undefined).pipe(
Effect.andThen(Deferred.await(cleanupRelease)),
Effect.andThen(Effect.sync(() => operations.push("release"))),
),
).pipe(
Effect.as(
Stream.fail(
new AIError({
reason: new TransportError({ message: "Request failed", transport: "http", operation: "request" }),
}),
),
),
),
),
TestLLM.stop(),
)
const run = yield* SessionStepMachine.run(s.assistantMessageID, {
prepare: (context) => Effect.sync(() => operations.push("prepare")).pipe(Effect.andThen(s.prepare(context))),
retry: () => Effect.sync(() => operations.push("retry")).pipe(Effect.asVoid),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => Deferred.succeed(cleanupRelease, undefined))
yield* Deferred.await(cleanupStarted)
expect(operations).toEqual(["prepare", "acquire"])
expect(yield* s.llm.requests()).toHaveLength(1)
expect(run.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(cleanupRelease, undefined)
expect(yield* Fiber.join(run)).toBe(false)
expect(operations).toEqual(["prepare", "acquire", "release", "retry", "prepare"])
expect(yield* s.llm.requests()).toHaveLength(2)
expect(yield* s.message).toMatchObject({ finish: "stop" })
}),
)
for (const providerExecuted of [false, true]) {
it.effect(
`commits ${providerExecuted ? "provider-hosted" : "local"} tool success during cancellation under the bus lock`,
() =>
Effect.gen(function* () {
const ready = yield* Deferred.make<void>()
const resultRelease = yield* Deferred.make<void>()
const publishing = yield* Deferred.make<void>()
const held = yield* Deferred.make<void>()
const lockRelease = yield* Deferred.make<void>()
const s = yield* setup({
observePublish: (type) =>
type === SessionEvent.Tool.Success.type ? Deferred.succeed(publishing, undefined) : Effect.void,
})
const call = LLMEvent.toolCall({ id: "call-race", name: "lookup", input: {}, providerExecuted })
let executions = 0
yield* s.llm.push(
providerExecuted
? Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), call]).pipe(
Stream.concat(
Stream.unwrap(
Deferred.succeed(ready, undefined).pipe(
Effect.andThen(Deferred.await(resultRelease)),
Effect.as(
Stream.make(
LLMEvent.toolResult({
id: call.id,
name: call.name,
providerExecuted: true,
result: { type: "text", value: "Durable result" },
}),
),
),
),
),
),
Stream.concat(Stream.never),
)
: TestLLM.hangAfter(LLMEvent.stepStart({ index: 0 }), call),
)
const run = yield* SessionStepMachine.run(s.assistantMessageID, {
prepare: (context) =>
s.prepare(context, {
executeTool: () =>
Effect.gen(function* () {
executions++
yield* Deferred.succeed(ready, undefined)
yield* Deferred.await(resultRelease)
return { content: "Durable result" }
}),
}),
retry: () => Effect.die("Unexpected retry"),
publishSynthetic: Effect.die("Unexpected continuation"),
}).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(ready)
yield* Effect.acquireRelease(
s.bus.listen((event) =>
event.type === SessionEvent.Renamed.type
? Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(lockRelease)))
: Effect.void,
),
(unsubscribe) => unsubscribe,
)
// Notifications hold the real aggregate lock after the unrelated event commits.
const holder = yield* s.bus
.publish(SessionEvent.Renamed, { sessionID: s.sessionID, title: "Hold publication" })
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => Deferred.succeed(lockRelease, undefined))
yield* Deferred.await(held)
yield* Deferred.succeed(resultRelease, undefined)
yield* Deferred.await(publishing)
const cancellation = yield* Fiber.interrupt(run).pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.yieldNow
expect(cancellation.pollUnsafe()).toBeUndefined()
expect(yield* s.events).not.toContain("session.tool.success.2")
yield* Deferred.succeed(lockRelease, undefined)
yield* Fiber.join(holder)
yield* Fiber.join(cancellation)
expect(Exit.hasInterrupts(yield* Fiber.await(run))).toBe(true)
expect(executions).toBe(providerExecuted ? 0 : 1)
expect(yield* s.llm.requests()).toHaveLength(1)
const events = yield* s.events
expect(events.filter((type) => type === "session.tool.success.2")).toHaveLength(1)
expect(events).not.toContain("session.tool.failed.2")
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(1)
expect(events.indexOf("session.tool.success.2")).toBeLessThan(events.indexOf("session.step.failed.1"))
expect(yield* s.message).toMatchObject({
finish: "error",
error: { type: "aborted" },
content: [
{
type: "tool",
id: call.id,
executed: providerExecuted,
state: { status: "completed", content: [{ type: "text", text: "Durable result" }] },
},
],
})
}),
)
}
it.effect("recovers overflow instead of generically retrying a subsequent transport failure", () =>
Effect.gen(function* () {
const s = yield* setup()
const contexts: SessionStepMachine.Context[] = []
const operations: string[] = []
yield* s.llm.push(
TestLLM.failAfter(
new AIError({
reason: new TransportError({ message: "Read failed", transport: "http", operation: "read" }),
}),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "Prompt too long", classification: "context-overflow" }),
),
TestLLM.stop(),
)
const result = yield* SessionStepMachine.run(s.assistantMessageID, {
prepare: (context) =>
Effect.sync(() => contexts.push(context)).pipe(
Effect.andThen(
s.prepare(context, {
recoverOverflow: Effect.sync(() => {
operations.push("compact")
return true
}),
}),
),
),
retry: () => Effect.sync(() => operations.push("retry")).pipe(Effect.asVoid),
publishSynthetic: Effect.die("Unexpected continuation"),
})
expect(result).toBe(false)
expect(operations).toEqual(["compact"])
expect(yield* s.llm.requests()).toHaveLength(2)
expect(contexts).toHaveLength(2)
expect(contexts[0]).toMatchObject({ assistantMessageID: s.assistantMessageID, recoverOverflow: true })
expect(contexts[1]).toMatchObject({ recoverOverflow: false })
expect(contexts[1]?.assistantMessageID).not.toBe(s.assistantMessageID)
expect(yield* s.events).not.toContain("session.step.failed.1")
}),
)
const setup = Effect.fnUntraced(function* (
options: {
readonly snapshot?: Pick<Snapshot.Interface, "capture" | "files">
readonly observePublish?: (type: string) => Effect.Effect<unknown>
} = {},
) {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const llm = yield* TestLLM.Test
const sessionID = Session.ID.create()
const assistantMessageID = SessionMessage.ID.create()
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
.run()
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 100_000, output: 1_000 },
cost: [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
},
],
},
)
const steps = yield* SessionStep.make.pipe(
Effect.provide(
Layer.mock(Snapshot.Service)(
options.snapshot ?? { capture: () => Effect.undefined, files: () => Effect.succeed([]) },
),
),
Effect.provideService(Bus.Service, {
...bus,
publish: (definition, data, publishOptions) =>
(options.observePublish?.(definition.type) ?? Effect.void).pipe(
Effect.andThen(bus.publish(definition, data, publishOptions)),
),
}),
)
return {
bus,
llm,
sessionID,
assistantMessageID,
prepare: (
context: SessionStepMachine.Context,
input?: {
readonly toolChoice?: "none"
readonly executeTool?: SessionStep.Input["prepared"]["executeTool"]
readonly recoverOverflow?: Effect.Effect<boolean>
},
) =>
steps
.open({
sessionID,
assistantMessageID: context.assistantMessageID,
agent: Agent.defaultID,
model,
prepared: {
request: LLM.request({ model: model.model, prompt: "Run one step", toolChoice: input?.toolChoice }),
options: {},
executeTool: input?.executeTool ?? (() => Effect.die("Unexpected tool execution")),
},
recoverContinuation: context.recoverContinuation,
recoverOverflow: input?.recoverOverflow ?? Effect.succeed(false),
})
.pipe(Effect.map((attempt) => SessionStepMachine.Preparation.Ready({ attempt }))),
message: db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, assistantMessageID))
.get()
.pipe(Effect.map((row) => row?.data)),
events: db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.map((rows) => rows.map((row) => row.type))),
}
})
+2 -2
View File
@@ -1,5 +1,5 @@
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { WebSearchApi } from "@opencode-ai/client/effect/api"
import type { WebsearchApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
@@ -9,7 +9,7 @@ export interface WebSearchDefinition {
readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
}
export interface WebSearchDomain extends WebSearchApi<unknown> {
export interface WebSearchDomain extends WebsearchApi<unknown> {
readonly transform: Transform<WebSearchDraft>
readonly reload: () => Effect.Effect<void>
}
+9 -7
View File
@@ -55,13 +55,15 @@ export const makeFormGroup = <
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
+9 -7
View File
@@ -90,13 +90,15 @@ export const makePermissionGroup = <
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", {
+5 -16
View File
@@ -1,6 +1,4 @@
import { Database } from "@opencode-ai/core/database/database"
import { Form } from "@opencode-ai/core/form"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import {
ConflictError,
FormAlreadySettledError,
@@ -8,10 +6,10 @@ import {
FormNotFoundError,
InvalidRequestError,
} from "@opencode-ai/protocol/errors"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location"
import { response } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
@@ -19,8 +17,6 @@ function missingForm(id: Form.ID) {
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const database = yield* Database.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
@@ -39,16 +35,9 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const ref =
ctx.params.sessionID === "global"
? requestRef(ctx.request)
: yield* sessionRef(database, ctx.params.sessionID)
const forms = yield* withLoadedLocationServices(
locations,
ref,
Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })),
)
return { data: Option.getOrElse(forms, () => []) }
const form = yield* Form.Service
const forms = yield* form.list({ sessionID: ctx.params.sessionID })
return { data: forms }
}),
)
.handle(
+4 -13
View File
@@ -1,13 +1,11 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { response, sessionRef, withLoadedLocationServices } from "../location"
import { response } from "../location"
function missingRequest(id: Permission.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
@@ -15,8 +13,6 @@ function missingRequest(id: Permission.ID) {
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const database = yield* Database.Service
const requireOwnedRequest = Effect.fnUntraced(function* (
sessionID: Permission.Request["sessionID"],
requestID: Permission.ID,
@@ -67,13 +63,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
const ref = yield* sessionRef(database, ctx.params.sessionID)
const requests = yield* withLoadedLocationServices(
locations,
ref,
Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID)),
)
return { data: Option.getOrElse(requests, () => []) }
const permission = yield* Permission.Service
return { data: yield* permission.forSession(ctx.params.sessionID) }
}),
)
.handle(
+1 -41
View File
@@ -1,13 +1,8 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Effect, Layer } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -31,41 +26,6 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
})
}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export function sessionRef(database: Context.Service.Shape<typeof Database.Service>, sessionID: unknown) {
return Effect.gen(function* () {
const id = yield* decodeSessionID(sessionID).pipe(
Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
)
const row = yield* database.db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, id))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new SessionNotFoundError({ sessionID: id, message: `Session not found: ${id}` })
return Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
})
})
}
export function withLoadedLocationServices<A, E>(
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
ref: Location.Ref,
effect: Effect.Effect<A, E, LocationServices>,
) {
return Effect.scoped(
Effect.gen(function* () {
const context = yield* locations.contextEffectOption(ref)
if (Option.isNone(context)) return Option.none<A>()
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
}),
)
}
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
@@ -1,10 +1,16 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { Effect, Layer } from "effect"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { requestRef, sessionRef, type LocationServices } from "../location"
import { requestRef, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
@@ -13,10 +19,12 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export const formLocationLayer = Layer.effect(
FormLocationMiddleware,
Effect.gen(function* () {
const database = yield* Database.Service
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return FormLocationMiddleware.of((effect) =>
@@ -30,8 +38,38 @@ export const formLocationLayer = Layer.effect(
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}
const ref = yield* sessionRef(database, route.params.sessionID)
return yield* effect.pipe(Effect.provide(locations.get(ref)))
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) {
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
}
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
)
}),
)
}),
@@ -1,10 +1,16 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { sessionRef, type LocationServices } from "../location"
import type { LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
@@ -13,17 +19,48 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export const sessionLocationLayer = Layer.effect(
SessionLocationMiddleware,
Effect.gen(function* () {
const database = yield* Database.Service
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return SessionLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
const ref = yield* sessionRef(database, route.params.sessionID)
return yield* effect.pipe(Effect.provide(locations.get(ref)))
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
)
}),
)
}),
-124
View File
@@ -4,7 +4,6 @@ import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -277,129 +276,6 @@ it.live("serves the session view operation and missing-session error", () =>
}),
)
it.live("does not load a location when reading pending session requests", () =>
Effect.gen(function* () {
const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-pending-read-")))
const handler = yield* ServerFetch.make({
...options,
config: {
directory: config.path,
project: false,
content: JSON.stringify({ permissions: [{ action: "shell", resource: "*", effect: "ask" }] }),
},
})
const created = (yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
).then((response) => response.json()),
)) as { data: { id: string } }
const loaded = () =>
Effect.promise(() =>
handler(new Request("http://opencode.local/api/debug/location")).then(
(response) => response.json() as Promise<unknown[]>,
),
)
expect(yield* loaded()).toEqual([])
for (const resource of ["permission", "form"]) {
const response = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`)),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
const missing = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`)),
)
expect(missing.status).toBe(404)
}
const global = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
}),
),
)
expect(global.status).toBe(200)
expect(yield* Effect.promise(() => global.json())).toEqual({ data: [] })
expect(yield* loaded()).toEqual([])
const createdForm = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/form`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Test form", fields: [{ key: "answer", type: "string" }] }),
}),
),
)
expect(createdForm.status).toBe(200)
const forms = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`)),
)
expect(forms.status).toBe(200)
expect(yield* Effect.promise(() => forms.json())).toMatchObject({
data: [{ title: "Test form" }],
})
expect(yield* loaded()).toHaveLength(1)
const globalForm = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencode-directory": encodeURIComponent(process.cwd()),
},
body: JSON.stringify({ title: "Global form", fields: [{ key: "answer", type: "string" }] }),
}),
),
)
expect(globalForm.status).toBe(200)
const globalForms = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
}),
),
)
expect(globalForms.status).toBe(200)
expect(yield* Effect.promise(() => globalForms.json())).toMatchObject({ data: [{ title: "Global form" }] })
// Agent permission policy is installed by plugin activation.
expect((yield* ready(handler)).status).toBe(200)
const createdPermission = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/permission`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: "per_pending_read", action: "shell", resources: ["pwd"] }),
}),
),
)
expect(createdPermission.status).toBe(200)
expect(yield* Effect.promise(() => createdPermission.json())).toEqual({
data: { id: "per_pending_read", effect: "ask" },
})
const permissions = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)),
)
expect(permissions.status).toBe(200)
expect(yield* Effect.promise(() => permissions.json())).toMatchObject({
data: [{ id: "per_pending_read", sessionID: created.data.id, action: "shell", resources: ["pwd"] }],
})
expect(yield* loaded()).toHaveLength(1)
}),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
-1
View File
@@ -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": {
-68
View File
@@ -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.
-175
View File
@@ -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)
}
+19 -26
View File
@@ -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 {
-45
View File
@@ -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}"`,
}
}
+3 -14
View File
@@ -220,22 +220,11 @@ const layer = Layer.effect(
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const streams =
options?.includeStderr === true
? yield* handle.stderr.pipe(
Stream.broadcastN({ n: 2, capacity: 16 }),
Effect.map((copies) => ({
source: Stream.merge(handle.stdout, copies[0]),
diagnostics: copies[1],
})),
)
: { source: handle.stdout, diagnostics: handle.stderr }
const stderrFiber = yield* Effect.forkScoped(
collectStream(streams.diagnostics, options?.maxErrorBytes).pipe(
Effect.map((x) => x.buffer.toString("utf8")),
),
collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
)
const lines = streams.source.pipe(
const source = options?.includeStderr === true ? handle.all : handle.stdout
const lines = source.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter((line) => line.length > 0),