mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 04:56:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91802603ea | ||
|
|
8089091e82 |
@@ -2,4 +2,3 @@ packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
packages/core/src/models-dev/snapshot.txt linguist-generated
|
||||
packages/core/src/**/*.txt text eol=lf
|
||||
packages/httpapi-codegen/test/generated/*.ts text eol=lf
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
|
||||
@@ -1073,9 +1073,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2212,27 +2212,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
"@opentui/core": ["@opentui/core@https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-darwin-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-iJKwj+aq0w0TYZu0xafb5Nj5eBObZcKc4+qHCpj5EAQBRCid3Z8nROmiwdO+aDDeqwTYzfG53u+MU3urEtiweQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-8Wo6B6VhFXu5Lq/HDXQCkxCJF4svJ3OPU+WcotQJuLT9/pXzik40WjTKVn3HECKiqQ3ryQS4ZxpnJIxlNDcpmg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-Tt+qGxp5VJZKmw54XdzebE6a+0QVA5Sk8KWUDHfWXn4ley6tvpMZowrG2/O6TpKHP6XdA/bHm+Ewetyk7BO2Zg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-p62IQHNFFQUzet57HMQndNQZtunDc0peSHudBoatCTV68QYKsD5gYjAK/BW1XY0zUoJM9Jk7BJnPBJPv1s5EYQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-qvQgkAdLSa1+SeMhMNBZ7zx7OlVi8yMsV+DDGTDrcc9sWXDzGJEKUq/R9XqqAag64Sloc12YA5oUYDKKj4E2VA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-JIkwBP6yLhh4ViTR53kx/NCY5w1ocTcKMdWidpObKLVLIkBat97y085teSvgkh+RFuJnUaJK7114XgdEONCjSg=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-M1BPZZ7eLE/nfnS7uypyIxg5JKfkkZSkehszISrQOf8zQl3+j3kOPy17RqSCJKZboGFQwmg8rwlaxGNxA6sfVQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-GFQ1GCA4FQg/MVpO1jt53X4fTaYsQTfW1EkOpQqmt/Rvls/oSklpElxh4ww5/giHEVnNxvcqX8+RG9U5DCmLpg=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-NKFUOLwBfd59koYiPgeqrP4hZERnG6B7+RnH2DNO3dAnIAl/7dISuOwWw/VacYBck4ToG0ZrbhBMTeBB2i5DqQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Lw+wxniAxIqnZz9vFPwl8EsH33HZGnyukQTU/4dI01BwNKbx2hcxuFMjrHFP/MkqhvwmFXcvGSGfJrG8VLwFoA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
"@opentui/solid": ["@opentui/solid@https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-wtq+offTD/Hxd9KXW5eVjeBgtBiJiXakkk1Jo/8ULX2fvG+trtdaAHr0DrlHsRv/xyUMfxPBaHRwPe4Gk8+8sw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -6564,6 +6564,10 @@
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
|
||||
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
|
||||
|
||||
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -7450,6 +7454,30 @@
|
||||
|
||||
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -8248,6 +8276,8 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-No3mCuG2tGQauX1HUpO+rebiWh+rrpSHUCC7rtXFu1s=",
|
||||
"aarch64-linux": "sha256-8joWv1iDkc6TejEukGBEX0wW8DPs55wJEPY7+9+HDdM=",
|
||||
"aarch64-darwin": "sha256-4MWGFQUIP1Ae4dujztTb4G8/uDJx2wiOoypcPSUWDbw=",
|
||||
"x86_64-darwin": "sha256-BtvnraCJmVagtA3Iv+EbWodjFG74sTd2Purqgo7Wkr4="
|
||||
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
|
||||
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
|
||||
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
|
||||
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -845,21 +845,6 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
const decodeMessagePart = Schema.decodeUnknownOption(
|
||||
Schema.Union([OpenResponsesOutputText, Schema.Struct({ type: Schema.tag("refusal"), refusal: Schema.String })]),
|
||||
)
|
||||
|
||||
const decodeSummaryPart = Schema.decodeUnknownOption(OpenResponsesReasoningSummaryText)
|
||||
|
||||
const decodeReasoningPart = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ type: Schema.tag("reasoning_text"), text: Schema.String }),
|
||||
)
|
||||
|
||||
const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
if (!parts.some((part) => part !== undefined && part.length > 0)) return undefined
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
@@ -1080,33 +1065,24 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state: ParserState,
|
||||
item: Event["item"],
|
||||
) {
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
|
||||
const item = event.item
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const message = state.message?.id === item.id ? state.message : undefined
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
const content: string[] = []
|
||||
for (const part of parts) {
|
||||
const decoded = Option.getOrUndefined(decodeMessagePart(part))
|
||||
if (!decoded) continue
|
||||
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
|
||||
}
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
message: message ? undefined : state.message,
|
||||
lifecycle: Lifecycle.textEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||
),
|
||||
message: state.message?.id === item.id ? undefined : state.message,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1161,33 +1137,17 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (isReasoningItem(item)) {
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
const summary: Array<string | undefined> = []
|
||||
for (const part of summaryParts) {
|
||||
const decoded = Option.getOrUndefined(decodeSummaryPart(part))
|
||||
// Keep missing entries so the array still matches the provider's summary indexes.
|
||||
summary.push(decoded?.text)
|
||||
}
|
||||
const reasoningParts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
const content: string[] = []
|
||||
for (const part of reasoningParts) {
|
||||
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
|
||||
if (decoded) content.push(decoded.text)
|
||||
}
|
||||
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
|
||||
const events: LLMEvent[] = []
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
const fragments = Object.entries(reasoningItem.summaryParts)
|
||||
let lifecycle = state.lifecycle
|
||||
for (const [index, status] of fragments) {
|
||||
if (status === "concluded") continue
|
||||
// Do not repeat earlier summaries that were already emitted as separate fragments.
|
||||
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
|
||||
}
|
||||
if (!reasoningItem.open) return [state, NO_EVENTS] satisfies StepResult
|
||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1207,13 +1167,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({
|
||||
id: item.id,
|
||||
providerMetadata: metadata,
|
||||
text: itemText,
|
||||
}),
|
||||
)
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1241,24 +1195,32 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
})
|
||||
|
||||
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
|
||||
let current = state
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (id === undefined) continue
|
||||
if (item.type !== "function_call" || !current.tools[id]) continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
}
|
||||
}
|
||||
const reconciled =
|
||||
event.type === "response.completed"
|
||||
? yield* Effect.reduce(
|
||||
event.response?.output ?? [],
|
||||
() => [state, NO_EVENTS] satisfies StepResult,
|
||||
([current, events], item) => {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (
|
||||
id === undefined ||
|
||||
((item.type !== "function_call" || !current.tools[id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[id]?.open))
|
||||
)
|
||||
return Effect.succeed([current, events] satisfies StepResult)
|
||||
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
|
||||
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
|
||||
)
|
||||
},
|
||||
)
|
||||
: ([state, NO_EVENTS] satisfies StepResult)
|
||||
const current = reconciled[0]
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(current.id, current.tools)
|
||||
: { tools: current.tools, events: NO_EVENTS }
|
||||
events.push(...pending.events)
|
||||
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
current.hasFunctionCall
|
||||
@@ -1384,7 +1346,7 @@ export const step = (state: ParserState, input: Event) => {
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event.item)
|
||||
return onOutputItemDone(state, event)
|
||||
}
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
|
||||
@@ -815,12 +815,7 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
|
||||
case "tool_calls":
|
||||
return "tool-calls" as const
|
||||
default:
|
||||
return yield* new AIError({
|
||||
reason: new UnknownProviderError({
|
||||
message: `Provider finish_reason: ${reason}`,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
return "unknown" as const
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1059,25 +1054,17 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
const incompleteTools = finishReason?.normalized === "content-filter" || finishReason?.normalized === "length"
|
||||
if (
|
||||
finishReason !== undefined &&
|
||||
!incompleteTools &&
|
||||
state.finishReason === undefined &&
|
||||
Object.keys(pendingTools).length
|
||||
)
|
||||
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
|
||||
// Filtering or truncation terminates the response without confirming pending tool calls.
|
||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||
// valid calls and malformed local calls settle independently.
|
||||
const finished =
|
||||
finishReason !== undefined &&
|
||||
!incompleteTools &&
|
||||
state.finishReason === undefined &&
|
||||
Object.keys(tools).length > 0
|
||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -76,8 +76,6 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
@@ -141,9 +139,6 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
|
||||
@@ -81,46 +81,6 @@ describe("provider error classification", () => {
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies retryable server messages as provider internal", () => {
|
||||
const message =
|
||||
"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing."
|
||||
|
||||
expect(
|
||||
[
|
||||
message,
|
||||
"Try again",
|
||||
"Please retry your request shortly.",
|
||||
"You can retry the request.",
|
||||
"Try your request again.",
|
||||
"The service is temporarily at capacity.",
|
||||
"The model is overloaded.",
|
||||
"Service unavailable",
|
||||
"Internal server error",
|
||||
"The server is busy.",
|
||||
"Provider returned error",
|
||||
"Provider returned an error",
|
||||
"ResourceExhausted",
|
||||
"Upstream connection failed",
|
||||
"Exceeded request buffer limit while retrying upstream",
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(Array(15).fill("ProviderInternal"))
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Provider request failed", rawBody: "Please try again later." })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("prioritizes specific failures over retryable server text", () => {
|
||||
expect(
|
||||
[
|
||||
classifyProviderFailure({ message: "Invalid credentials, try again", status: 401 }),
|
||||
classifyProviderFailure({ message: "Quota exceeded, try again", status: 429 }),
|
||||
classifyProviderFailure({ message: "Rate limit exceeded, try again" }),
|
||||
classifyProviderFailure({ message: "Upstream request failed: validation failed", status: 400 }),
|
||||
classifyProviderFailure({ message: "Try again", status: 200 }),
|
||||
].map((failure) => failure._tag),
|
||||
).toEqual(["Authentication", "QuotaExceeded", "RateLimit", "InvalidRequest", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
@@ -151,6 +111,7 @@ describe("provider error classification", () => {
|
||||
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "Provider returned error" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const request = LLM.request({
|
||||
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
prompt: "Respond.",
|
||||
})
|
||||
const completed = { type: "response.completed", response: { id: "resp_1" } }
|
||||
const generate = (...events: OpenResponses.Event[]) =>
|
||||
LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...events))))
|
||||
|
||||
describe("Open Responses completed item text", () => {
|
||||
;["Draft expanded", "D", "Replacement", ""].forEach((text) => {
|
||||
it.effect(`replaces streamed text with completed item text ${JSON.stringify(text)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "Part final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", phase: "final_answer", content: [{ type: "output_text", text }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe(text)
|
||||
expect(response.events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Draft"])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
text,
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "final_answer" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("joins completed text and refusal parts without streamed text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [
|
||||
{ type: "output_text", text: "Answer. " },
|
||||
{ type: "refusal", refusal: "Cannot help." },
|
||||
],
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe("Answer. Cannot help.")
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not create an empty text fragment for an empty completed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "" }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.message.content).toEqual([])
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Open Responses completed item reasoning", () => {
|
||||
;[
|
||||
{
|
||||
name: "summary",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "Final" },
|
||||
{ type: "summary_text", text: "summary" },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Raw" }],
|
||||
text: "Final\n\nsummary",
|
||||
},
|
||||
{
|
||||
name: "raw text",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "" },
|
||||
{ type: "summary_text", text: "" },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Raw" }],
|
||||
text: "Raw",
|
||||
},
|
||||
{
|
||||
name: "streamed fallback",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "" },
|
||||
{ type: "summary_text", text: "" },
|
||||
],
|
||||
content: [
|
||||
{ type: "reasoning_text", text: "" },
|
||||
{ type: "reasoning_text", text: "" },
|
||||
],
|
||||
text: "Draft",
|
||||
},
|
||||
].forEach((fixture) => {
|
||||
it.effect(`uses ${fixture.name} at item completion`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: fixture.summary,
|
||||
content: fixture.content,
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe(fixture.text)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("replaces only the still-open summary without repeating earlier text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First " },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "draft" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First " },
|
||||
{ type: "summary_text", text: "final" },
|
||||
],
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe("First final")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
;["response.completed", "response.incomplete"].forEach((type) => {
|
||||
it.effect(`keeps streamed text when part finals are followed by ${type} without item completion`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 0, delta: "Hel" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 1, delta: "world" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "Hello " },
|
||||
{
|
||||
type: "response.content_part.done",
|
||||
item_id: "msg_1",
|
||||
content_index: 0,
|
||||
part: { type: "output_text", text: "Hello " },
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
|
||||
{
|
||||
type: "response.reasoning_summary_part.done",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "Part final" },
|
||||
},
|
||||
{
|
||||
type,
|
||||
response: {
|
||||
id: "resp_1",
|
||||
incomplete_details: type === "response.incomplete" ? { reason: "max_output_tokens" } : undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(response.text).toBe("Helworld")
|
||||
expect(response.reasoning).toBe("Draft")
|
||||
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([undefined])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -129,7 +129,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
|
||||
it.effect("preserves done-only encrypted reasoning without replaying its summary or late events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
@@ -157,7 +157,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
text: "Not streamed",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
@@ -302,7 +301,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
|
||||
it.effect("recovers pending items in completed output order with terminal encrypted metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{
|
||||
@@ -326,6 +325,11 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
},
|
||||
)
|
||||
expect(events.slice(5, -2)).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
@@ -337,10 +341,8 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "final" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
|
||||
},
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1460,162 +1460,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when content is filtered", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
|
||||
}),
|
||||
deltaChunk({}, "content_filter"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter", raw: "content_filter" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "content-filter", raw: "content_filter" }, usage: undefined },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
|
||||
const missingIdentity = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
|
||||
deltaChunk({}, "content_filter"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(missingIdentity.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(missingIdentity.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when output is truncated", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } }],
|
||||
}),
|
||||
deltaChunk({}, "length"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "length", raw: "length" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "length", raw: "length" }, usage: undefined },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
|
||||
const missingIdentity = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
|
||||
deltaChunk({}, "length"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(missingIdentity.finishReason).toEqual({ normalized: "length", raw: "length" })
|
||||
expect(missingIdentity.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unknown finish reasons without finalizing streamed tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
|
||||
}),
|
||||
deltaChunk({}, "future_reason"),
|
||||
)
|
||||
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
|
||||
const error = yield* LLMClient.stream(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedResponse(body)),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider finish_reason: future_reason",
|
||||
})
|
||||
expect(yield* Ref.get(events)).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores empty identity fields on later tool call deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -554,19 +554,6 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
})
|
||||
|
||||
const unknown = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(unknown).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider finish_reason: future_reason",
|
||||
})
|
||||
expect(decodeJson(unknown.reason.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_fixture",
|
||||
choices: [{ finish_reason: "future_reason" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -594,13 +581,17 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves content-filter finishes in the common reason algebra", () =>
|
||||
it.effect("preserves provider finish outcomes in the common reason algebra", () =>
|
||||
Effect.gen(function* () {
|
||||
const filtered = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))),
|
||||
)
|
||||
const future = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
)
|
||||
|
||||
expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("Open Responses-compatible route", () => {
|
||||
})
|
||||
|
||||
routings.forEach((routing) => {
|
||||
it.effect(`preserves reasoning summary boundaries without terminal reconciliation with ${routing.name}`, () =>
|
||||
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const address = { item_id: routing.item_id, output_index: routing.output_index }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -444,18 +444,21 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "reasoning",
|
||||
text: "Second.",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:0`,
|
||||
text: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
},
|
||||
{ type: "reasoning-end", id: `${routing.id}:1` },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:1`,
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -668,7 +671,7 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores terminal reasoning output when item completion is missing", () =>
|
||||
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -694,9 +697,8 @@ describe("Open Responses-compatible route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "rs_raw:0",
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2554,7 +2554,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores terminal reasoning output when item completion is missing", () =>
|
||||
it.effect("preserves terminal reasoning metadata when output item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2595,13 +2595,29 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.reasoning).toBe("Checked the diff.")
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
])
|
||||
expect(response.message.content).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "terminal-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2628,7 +2644,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers pending function calls without reconciling terminal reasoning", () =>
|
||||
it.effect("reconciles pending reasoning and function calls in completed output order", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2666,15 +2682,14 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
|
||||
])
|
||||
expect(response.events.findIndex(LLMEvent.is.toolCall)).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "reasoning-end"),
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.toolCall),
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
@@ -3004,7 +3019,6 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
src/assets/theme.css
|
||||
e2e/test-results
|
||||
e2e/performance/results/
|
||||
e2e/playwright-report
|
||||
component-tests/test-results
|
||||
component-tests/playwright-report
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const replace = Element.prototype.replaceChildren
|
||||
Element.prototype.replaceChildren = function (this: Element, ...nodes) {
|
||||
// The ref can run before data-component is assigned, so count on every target.
|
||||
this.setAttribute("data-test-replacements", String(Number(this.getAttribute("data-test-replacements")) + 1))
|
||||
return replace.apply(this, nodes)
|
||||
}
|
||||
})
|
||||
const component = await mount("opencode-composer-flow--failed-submission-restoration")
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(input).toHaveText("Preserve this draft on failure")
|
||||
await expect(input).toHaveAttribute("data-test-replacements", "1")
|
||||
|
||||
await input.press("Home")
|
||||
await input.press("Shift+ArrowRight")
|
||||
await input.pressSequentially("XY")
|
||||
await expect(input).toHaveText("XYreserve this draft on failure")
|
||||
await expect(input).toHaveAttribute("data-test-replacements", "1")
|
||||
|
||||
// Closing the model picker restores the controller's saved caret through its editor ref.
|
||||
await component.locator('[data-action="composer-model"]').click()
|
||||
await page.getByRole("menu").getByRole("textbox").press("Escape")
|
||||
await expect(input).toBeFocused()
|
||||
await input.pressSequentially("!")
|
||||
await expect(input).toHaveText("XY!reserve this draft on failure")
|
||||
await component.getByRole("button", { name: "Send", exact: true }).click()
|
||||
await expect(component.getByRole("status")).toHaveText("Submission failed; draft restored")
|
||||
await expect(input).toHaveText("Preserve this draft on failure")
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
|
||||
story("shows the thinking level control while relevant", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { LanguageProvider } from "../src/runtime/i18n/language"
|
||||
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
|
||||
|
||||
export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
|
||||
const host = document.createElement("main")
|
||||
host.dataset.testid = "timeline-virtualizer-fixture"
|
||||
host.dataset.scrolls = "0"
|
||||
host.dataset.viewportResizes = "0"
|
||||
host.style.cssText = "position:fixed;top:24px;right:24px;width:400px;z-index:1000"
|
||||
document.body.appendChild(host)
|
||||
|
||||
function Fixture() {
|
||||
const [state, setState] = createStore({ pinned: true, ready: false })
|
||||
const rows = Array.from(
|
||||
{ length: input.count },
|
||||
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
|
||||
)
|
||||
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
|
||||
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
|
||||
let viewport!: HTMLDivElement
|
||||
let content!: HTMLDivElement
|
||||
let container!: HTMLDivElement
|
||||
const timeline = createTimelineVirtualizer({
|
||||
sessionKey: () => "cold-reveal-fixture",
|
||||
projection: {
|
||||
rows: () => rows,
|
||||
rowByKey: () => rowByKey,
|
||||
activeMessageID: () => undefined,
|
||||
messageRowIndex: () => indexes,
|
||||
messageLastRowIndex: () => indexes,
|
||||
},
|
||||
showHeader: () => false,
|
||||
pinned: () => state.pinned,
|
||||
scroll: () => ({ overflow: false, jump: false }),
|
||||
setScrollRef: (element) => {
|
||||
if (!element) return
|
||||
viewport = element
|
||||
resize.observe(element, { box: "border-box" })
|
||||
},
|
||||
setContentRef: (element) => {
|
||||
content = element
|
||||
reveal.observe(element, { attributes: true, attributeFilter: ["style"] })
|
||||
},
|
||||
onPin: () => setState("pinned", true),
|
||||
onUnpin: () => setState("pinned", false),
|
||||
onScheduleScrollState: (element) => {
|
||||
host.dataset.scrolls = String(Number(host.dataset.scrolls) + 1)
|
||||
host.dataset.lastScrollTop = String(element.scrollTop)
|
||||
},
|
||||
onResumeScroll: () => {},
|
||||
onSelectionInteraction: () => {},
|
||||
onUserScroll: () => {},
|
||||
onHistoryScroll: () => {},
|
||||
canRenderImmediately: () => input.immediate ?? false,
|
||||
})
|
||||
|
||||
const resize = new ResizeObserver((entries) => {
|
||||
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
|
||||
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
|
||||
})
|
||||
const reveal = new MutationObserver(() => {
|
||||
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
|
||||
// Capture the first reveal, not a later frame after geometry has recovered.
|
||||
const mounted = [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
host.dataset.firstReveal = JSON.stringify({
|
||||
rows: mounted.map((element) => Number(element.firstElementChild!.getAttribute("data-index"))),
|
||||
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
|
||||
viewportHeight: viewport.clientHeight,
|
||||
scrollTop: viewport.scrollTop,
|
||||
clipped: mounted
|
||||
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
|
||||
.map((element) => element.dataset.timelineKey),
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
resize.disconnect()
|
||||
reveal.disconnect()
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-testid="timeline-controls" data-pinned={state.pinned}>
|
||||
<button type="button" onClick={() => setState("ready", true)}>
|
||||
Complete Markdown
|
||||
</button>
|
||||
<button type="button" onClick={() => (container.style.display = "none")}>
|
||||
Hide viewport
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const parent = viewport.parentElement!
|
||||
host.dataset.scrolls = "0"
|
||||
// Keep the same scroller and complete Markdown while it has no layout box.
|
||||
viewport.remove()
|
||||
viewport.scrollTop = 0
|
||||
setState("ready", true)
|
||||
parent.prepend(viewport)
|
||||
container.style.removeProperty("display")
|
||||
}}
|
||||
>
|
||||
Reconnect ready rows
|
||||
</button>
|
||||
<div ref={container} style={{ height: "180px", width: "400px" }}>
|
||||
<timeline.View
|
||||
header={null}
|
||||
workspaceSession={() => false}
|
||||
deferred={() => false}
|
||||
renderRow={(row) => (
|
||||
<div
|
||||
data-component="markdown"
|
||||
data-markdown-ready={state.ready ? "" : undefined}
|
||||
style={{ height: `${input.rowHeight}px` }}
|
||||
>
|
||||
{row().userMessageID}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<Fixture />
|
||||
</LanguageProvider>
|
||||
),
|
||||
host,
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./timeline-virtualizer.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--mixed-attachments")
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer({ count: 100, rowHeight: 60, immediate: true })
|
||||
}, fixture)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(root).toHaveAttribute("data-observed-height", "180")
|
||||
await expect(content).toHaveCSS("visibility", "hidden")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(4)
|
||||
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
|
||||
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
|
||||
rows: [96, 97, 98, 99],
|
||||
pendingMarkdown: 0,
|
||||
clipped: [],
|
||||
viewportHeight: 180,
|
||||
})
|
||||
})
|
||||
|
||||
for (const input of [
|
||||
{ name: "offset-only", count: 1, rowHeight: 600 },
|
||||
{ name: "zero-height", count: 4, rowHeight: 60 },
|
||||
]) {
|
||||
story(`reveals ready measured rows after an ${input.name} reconnect`, async ({ page }) => {
|
||||
await page.evaluate(
|
||||
async ({ fixture, input }) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer(input)
|
||||
},
|
||||
{ fixture, input },
|
||||
)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(root).toHaveAttribute("data-observed-height", "180")
|
||||
await expect(content).toHaveCSS("visibility", "hidden")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(1)
|
||||
|
||||
if (input.name === "offset-only") {
|
||||
await expect(root).toHaveAttribute("data-last-scroll-top", "484")
|
||||
await root.locator("[data-scrollable]").dispatchEvent("wheel", { deltaY: -1 })
|
||||
await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false")
|
||||
}
|
||||
if (input.name === "zero-height") {
|
||||
await root.getByRole("button", { name: "Hide viewport", exact: true }).click()
|
||||
// Wait for ResizeObserver to clear the actual range, not just for display:none.
|
||||
await expect(root).toHaveAttribute("data-observed-height", "0")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(0)
|
||||
}
|
||||
await expect(root).not.toHaveAttribute("data-first-reveal")
|
||||
const resizes = await root.getAttribute("data-viewport-resizes")
|
||||
await root.getByRole("button", { name: "Reconnect ready rows", exact: true }).click()
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
|
||||
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
|
||||
rows: input.count === 1 ? [0] : [0, 1, 2, 3],
|
||||
pendingMarkdown: 0,
|
||||
clipped: [],
|
||||
viewportHeight: 180,
|
||||
...(input.name === "offset-only" ? { scrollTop: 0 } : {}),
|
||||
})
|
||||
if (input.name === "offset-only") {
|
||||
// This repair must not depend on another native scroll or resize delivery.
|
||||
await expect(root).toHaveAttribute("data-scrolls", "0")
|
||||
await expect(root).toHaveAttribute("data-viewport-resizes", resizes!)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -65,7 +65,7 @@ The fixture requires every benchmark to call `report()`, automatically names and
|
||||
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
|
||||
```
|
||||
|
||||
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, optional trace path, and trace scope before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide; the default window is page lifetime. Tab-switch traces begin after scenario setup and include explicit interaction markers. Scenario metrics use their own narrower observation windows.
|
||||
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
|
||||
|
||||
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
|
||||
|
||||
@@ -81,60 +81,13 @@ Committed smoke and regression tests continue to own correctness coverage for pa
|
||||
|
||||
Tab-switch timing starts at `mousedown`, when mouse-selected tabs actually navigate, with a `click` fallback for keyboard activation. The probe excludes hidden/transparent content and intersects answers with their virtual-row clip and viewport. The tab workload requires the destination's final answer to be visible with Markdown ready. These results are not directly comparable to older click-start, geometry-only measurements. `stableObservedMs` includes confirmation across three correct samples; `firstCorrectObservedMs` is the first sample meeting all content and geometry checks. Neither is a compositor presentation timestamp.
|
||||
|
||||
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=20` for a baseline distribution. Warm scenarios prepare the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
|
||||
|
||||
The tab-switch workload uses two equally long sessions: 200 user/assistant exchanges (400 messages) per tab. Every answer includes headings, emphasis, links, a blockquote, task and nested lists, an eight-row table, and four highlighted code fences (TSX, JSON, SQL, Bash), alongside the stress fixture's reasoning and tools. The mock API deliberately returns all 400 messages in one response so every scenario measures a long loaded history, not a short paginated tail. The viewport is fixed at 1440 x 900. Results include the fixture version, Markdown and serialized-message byte counts, and message-request count. These numbers are not directly comparable to the earlier 12-exchange source / 72-exchange destination fixture.
|
||||
|
||||
Cold means the destination transcript has never rendered in that fresh browser context. Warm means its complex answer was rendered and ready before switching away and back. Both use the app's normal restored-tab data prefetch, which completes before measurement; neither includes app startup, the source session's Markdown engine initialization, or a cold backend fetch. The suite asserts no message fetch during either measured switch. Setup waits for mounted Markdown to finish and for the review-pane width transition to complete. Service workers are blocked to exclude the web build's background asset precache from this renderer benchmark. Screenshots are attached after measurement for the first repetition; Playwright video and trace recording are disabled for this workload, while opt-in Chrome profiling remains available. For a baseline distribution, use `--repeat-each=20 --retries=0`, keep profiling disabled, and report the median and p95 of `firstCorrectObservedMs` separately from the three-observation `stableObservedMs`.
|
||||
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=5` for repeated measurements. Cached scenarios warm the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
|
||||
|
||||
```sh
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --retries=0
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
|
||||
```
|
||||
|
||||
**The tab-switch fixture is not an end-to-end cold-data benchmark.** It prefetches destination messages and returns full history. Measure cold API navigation, Home-row opening, and prefetched-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
|
||||
|
||||
Keep one-off reports, recorded results, and traces outside git, in the ignored `e2e/performance/results/` directory or an external artifact directory. Preserve raw observations locally and publish anonymized summaries and charts in the PR description, not as committed experiment files.
|
||||
|
||||
For a repeatable tab-switch summary, run from `packages/app`:
|
||||
|
||||
```sh
|
||||
bun run bench:tabs
|
||||
```
|
||||
|
||||
This runs only the tab-switch benchmark against the production build with 20 serial repetitions and no retries. It prints the median (mean of the two middle values for even sample counts) and nearest-rank p95 for `firstCorrectObservedMs` and `stableObservedMs` per scenario. Only records whose benchmark and Playwright statuses are passed and whose two metrics are finite enter the summary. Test and record statuses, missing records, and excluded samples are reported separately.
|
||||
|
||||
For fresh entry paths, run `bun run bench:entry` from `packages/app`. It uses the same production, serial-repetition, and reporting defaults. The cases open an empty draft from the actual Home button, create a draft with the titlebar plus from an active session, and open a cold paginated session from Home. Draft readiness requires a focused editable composer, the expected model, project control, and new tab; typing and absence of backend mutations are checked afterward. Session readiness requires the latest group, ready answer Markdown, and bottom anchoring. These cases are separate from prefetched tab remounts.
|
||||
|
||||
For milestone charts, rerun frozen builds with one workload and counterbalanced serial order. Do not connect historical medians from different transports, preparation, or machine-load periods. Show samples or ranges, name the checkpoints accurately, and distinguish experimental build snapshots from Git commits.
|
||||
|
||||
Complete original `BENCHMARK` JSON records, including samples, context, and failed records, are saved as `tab-switch-benchmark.jsonl` in Playwright's configured output directory (default: `e2e/test-results/performance`). Standard Playwright flags can override defaults when appended:
|
||||
|
||||
```sh
|
||||
bun run bench:tabs --repeat-each=3 --output=e2e/test-results/tabs-smoke
|
||||
```
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_MEMORY=1` for an opt-in renderer-main-isolate heap and DOM sample after mounted content is ready and an explicit GC completes. Probe DOM references are released before collection. This is not total desktop memory; do not mix these diagnostic runs with unprofiled latency samples. Set `OPENCODE_PERFORMANCE_TRACE_DIR` for a separate Chrome trace of each tab interaction, starting after preparation, with `session-switch:start`, `session-switch:ready`, and `session-switch:stable` markers.
|
||||
|
||||
### Cache-Enabled HTTP Fixture
|
||||
|
||||
The default tab harness uses Playwright routing for API responses. Playwright routing disables the browser HTTP cache, including for unrelated SVG assets. To measure with HTTP caching enabled, the same API handlers and tab data can run on a real loopback HTTP endpoint:
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
bun e2e/performance/tab-switch-server.ts --port 4639 --dist dist
|
||||
```
|
||||
|
||||
With that fixture running, run the benchmark in a separate terminal from `packages/app`:
|
||||
|
||||
```powershell
|
||||
$env:PLAYWRIGHT_BASE_URL = "http://127.0.0.1:4639"
|
||||
$env:OPENCODE_PERFORMANCE_HTTP_FIXTURE = "1"
|
||||
bun run bench:tabs
|
||||
```
|
||||
|
||||
Use `--dist` to select a frozen production bundle when comparing revisions. An explicit `PLAYWRIGHT_BASE_URL` means the benchmark does not rebuild or start another preview. The fixture gives hashed assets immutable cache headers; it serves the deterministic read workload, not the live OpenCode service. Each test still gets a fresh browser context, and source-session setup still occurs before the measured switch. API responses use `no-store`, service workers remain blocked, and no destination Markdown is rendered before a cold switch. Records identify the transport as `http` or `playwright-route`; keep these series separate. Unset `OPENCODE_PERFORMANCE_HTTP_FIXTURE` when returning to the default routed harness.
|
||||
|
||||
## Retained renderer memory
|
||||
|
||||
Run the catalog workload against the production app bundle:
|
||||
@@ -158,7 +111,7 @@ bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
|
||||
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
|
||||
|
||||
Trace capture follows [Puppeteer's tracing lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. V8 CPU sample stacks support attribution through the frozen build's source maps. Set `OPENCODE_PERFORMANCE_STACK_TRACE=1` only when per-event timeline stacks are needed; they add substantial overhead. Keep profiled runs separate from latency distributions, including when comparing the stack-capture modes.
|
||||
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
|
||||
|
||||
```sh
|
||||
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
|
||||
|
||||
@@ -5,20 +5,16 @@ type BenchmarkFixtures = {
|
||||
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
|
||||
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
|
||||
benchmarkResult: void
|
||||
traceScope: "page" | "interaction"
|
||||
}
|
||||
|
||||
export type PerformancePageDiagnostics = {
|
||||
navigations: string[]
|
||||
traceScope: "page" | "interaction"
|
||||
startTrace: () => Promise<void>
|
||||
stop: () => Promise<string | undefined>
|
||||
}
|
||||
|
||||
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
|
||||
|
||||
export const benchmark = base.extend<BenchmarkFixtures>({
|
||||
traceScope: ["page", { option: true }],
|
||||
reportState: async ({}, use) => use({}),
|
||||
report: async ({ reportState }, use) => {
|
||||
await use((metrics, context = {}) => {
|
||||
@@ -53,9 +49,9 @@ export const benchmark = base.extend<BenchmarkFixtures>({
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
page: async ({ page, traceScope }, use, testInfo) => {
|
||||
page: async ({ page }, use, testInfo) => {
|
||||
const name = benchmarkName(testInfo)
|
||||
const diagnostics = await observePerformancePage(page, name, traceScope)
|
||||
const diagnostics = await observePerformancePage(page, name)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
@@ -79,30 +75,25 @@ function benchmarkName(testInfo: TestInfo) {
|
||||
|
||||
export { expect }
|
||||
|
||||
async function observePerformancePage(page: Page, name: string, traceScope: "page" | "interaction" = "page") {
|
||||
async function observePerformancePage(page: Page, name: string) {
|
||||
const navigations: string[] = []
|
||||
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
|
||||
if (frame === page.mainFrame()) navigations.push(frame.url())
|
||||
}
|
||||
page.on("framenavigated", onNavigation)
|
||||
let stopTrace: Awaited<ReturnType<typeof startChromeTrace>>
|
||||
const stopTrace = await startChromeTrace(page, name).catch((error) => {
|
||||
page.off("framenavigated", onNavigation)
|
||||
throw error
|
||||
})
|
||||
let stopping: Promise<string | undefined> | undefined
|
||||
const diagnostics: PerformancePageDiagnostics = {
|
||||
navigations,
|
||||
traceScope,
|
||||
async startTrace() {
|
||||
stopTrace ??= await startChromeTrace(page, name).catch((error) => {
|
||||
page.off("framenavigated", onNavigation)
|
||||
throw error
|
||||
})
|
||||
},
|
||||
stop() {
|
||||
page.off("framenavigated", onNavigation)
|
||||
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
|
||||
},
|
||||
}
|
||||
pages.set(page, diagnostics)
|
||||
if (traceScope === "page") await diagnostics.startTrace()
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
@@ -139,7 +130,6 @@ async function reportPerformancePage(name: string, diagnostics: PerformancePageD
|
||||
context: {
|
||||
platform: process.platform,
|
||||
trace,
|
||||
traceScope: diagnostics.traceScope,
|
||||
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
|
||||
},
|
||||
navigations: diagnostics.navigations,
|
||||
|
||||
@@ -14,6 +14,7 @@ const categories = [
|
||||
"blink.console",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
]
|
||||
|
||||
@@ -33,9 +34,6 @@ export async function startChromeTrace(page: Page, name: string): Promise<undefi
|
||||
.map((category) => category.slice(1)),
|
||||
includedCategories: [
|
||||
...categories.filter((category) => !category.startsWith("-")),
|
||||
...(process.env.OPENCODE_PERFORMANCE_STACK_TRACE === "1"
|
||||
? ["disabled-by-default-devtools.timeline.stack"]
|
||||
: []),
|
||||
...(selectors
|
||||
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
|
||||
: []),
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
type BenchmarkRecord = {
|
||||
status?: string
|
||||
metrics?: { firstCorrectObservedMs?: unknown; stableObservedMs?: unknown } | null
|
||||
}
|
||||
|
||||
export default class TabSwitchReporter implements Reporter {
|
||||
private output = ""
|
||||
private tests: TestCase[] = []
|
||||
private results: { test: TestCase; status: TestResult["status"]; records: string[] }[] = []
|
||||
|
||||
onBegin(config: FullConfig, suite: Suite) {
|
||||
this.output = config.projects[0].outputDir
|
||||
this.tests = suite.allTests()
|
||||
}
|
||||
|
||||
onTestEnd(test: TestCase, result: TestResult) {
|
||||
this.results.push({
|
||||
test,
|
||||
status: result.status,
|
||||
records: Buffer.concat(result.stdout.map((chunk) => (typeof chunk === "string" ? Buffer.from(chunk) : chunk)))
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("BENCHMARK "))
|
||||
.map((line) => line.slice("BENCHMARK ".length)),
|
||||
})
|
||||
}
|
||||
|
||||
async onEnd(result: FullResult) {
|
||||
const file = path.join(this.output, "tab-switch-benchmark.jsonl")
|
||||
try {
|
||||
await mkdir(this.output, { recursive: true })
|
||||
await writeFile(file, this.results.flatMap((entry) => entry.records.map((raw) => `${raw}\n`)).join(""), "utf8")
|
||||
} catch (error) {
|
||||
console.error("Could not save tab-switch benchmark records:", error)
|
||||
return { status: "failed" as const }
|
||||
}
|
||||
|
||||
console.log(`\nTab-switch benchmark: ${result.status}`)
|
||||
Array.from(new Set(this.tests.map((test) => test.title))).forEach((name) => {
|
||||
const results = this.results.filter((entry) => entry.test.title === name)
|
||||
const unrun = this.tests.filter(
|
||||
(test) => test.title === name && !results.some((entry) => entry.test.id === test.id),
|
||||
).length
|
||||
const records = results.flatMap((entry) =>
|
||||
entry.records.map((raw) => {
|
||||
try {
|
||||
return { status: entry.status, record: JSON.parse(raw) as BenchmarkRecord | null }
|
||||
} catch {
|
||||
return { status: entry.status, record: { status: "invalid JSON", metrics: null } }
|
||||
}
|
||||
}),
|
||||
)
|
||||
const passed = records.filter((entry) => entry.status === "passed" && entry.record?.status === "passed")
|
||||
const valid = passed
|
||||
.map((entry) => ({
|
||||
firstCorrectObservedMs: entry.record?.metrics?.firstCorrectObservedMs,
|
||||
stableObservedMs: entry.record?.metrics?.stableObservedMs,
|
||||
}))
|
||||
.filter(
|
||||
(metrics): metrics is { firstCorrectObservedMs: number; stableObservedMs: number } =>
|
||||
typeof metrics.firstCorrectObservedMs === "number" &&
|
||||
Number.isFinite(metrics.firstCorrectObservedMs) &&
|
||||
typeof metrics.stableObservedMs === "number" &&
|
||||
Number.isFinite(metrics.stableObservedMs),
|
||||
)
|
||||
|
||||
console.log(`\n${name}`)
|
||||
console.log(` Tests: ${counts(results.map((entry) => entry.status))}; unrun=${unrun}`)
|
||||
console.log(
|
||||
` Records: ${counts(records.map((entry) => entry.record?.status ?? "missing status"))}; ` +
|
||||
`missing=${results.filter((entry) => entry.records.length === 0).length + unrun}; ` +
|
||||
`excluded=${records.length - valid.length}; invalid metrics=${passed.length - valid.length}`,
|
||||
)
|
||||
;(["firstCorrectObservedMs", "stableObservedMs"] as const).forEach((metric) => {
|
||||
const values = valid.map((entry) => entry[metric]).sort((a, b) => a - b)
|
||||
if (values.length === 0) {
|
||||
console.log(` ${metric}: n=0, median=n/a, p95=n/a`)
|
||||
return
|
||||
}
|
||||
const median = (values[Math.floor((values.length - 1) / 2)] + values[Math.floor(values.length / 2)]) / 2
|
||||
const p95 = values[Math.ceil(values.length * 0.95) - 1]
|
||||
console.log(` ${metric}: n=${values.length}, median=${median.toFixed(2)} ms, p95=${p95.toFixed(2)} ms`)
|
||||
})
|
||||
})
|
||||
console.log(`\nRaw BENCHMARK records: ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
function counts(statuses: string[]) {
|
||||
return (
|
||||
Array.from(new Set(statuses))
|
||||
.map((status) => `${status}=${statuses.filter((value) => value === status).length}`)
|
||||
.join(", ") || "none"
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import path from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
import { createMockServerHandler } from "../utils/mock-server"
|
||||
import { fixture } from "./timeline/session-timeline-stress.fixture"
|
||||
import { messages } from "./timeline/session-tab-switch.fixture"
|
||||
import { createReviewDiffs } from "./timeline/timeline-test-helpers"
|
||||
|
||||
const args = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: { port: { type: "string", default: "4639" }, dist: { type: "string", default: "dist" } },
|
||||
})
|
||||
const directory = path.resolve(args.values.dist)
|
||||
const api = createMockServerHandler({
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
|
||||
vcsDiff: createReviewDiffs(),
|
||||
})
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(args.values.port),
|
||||
idleTimeout: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"id":"evt_fixture_connected","type":"server.connected","data":{}}\n\n'),
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream", "cache-control": "no-store" } },
|
||||
)
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
const response = await api.handler(request)
|
||||
response.headers.set("cache-control", "no-store")
|
||||
return response
|
||||
}
|
||||
const file = Bun.file(path.join(directory, url.pathname))
|
||||
if (!url.pathname.endsWith("/") && (await file.exists())) {
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"cache-control": url.pathname.startsWith("/_assets/") ? "public, max-age=31536000, immutable" : "no-cache",
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response(Bun.file(path.join(directory, "index.html")), { headers: { "cache-control": "no-cache" } })
|
||||
},
|
||||
})
|
||||
console.log(`Tab fixture: ${server.url} (${directory})`)
|
||||
const close = async () => {
|
||||
await server.stop(true)
|
||||
await api.dispose()
|
||||
}
|
||||
process.once("SIGINT", close)
|
||||
process.once("SIGTERM", close)
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { NavigationMilestoneSample } from "./navigation-milestones"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
|
||||
benchmark(
|
||||
"navigation milestones start at mousedown and wait for the expected ready controls",
|
||||
async ({ page, report }) => {
|
||||
await page.setContent('<button id="open">Open</button><input id="editor" disabled><span id="model">Loading</span>')
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: "#open",
|
||||
milestones: { editor: { selector: "#editor:enabled:focus" }, model: { selector: "#model", text: "Ready model" } },
|
||||
navigate: async () => {
|
||||
await page.getByRole("button", { name: "Open", exact: true }).dispatchEvent("mousedown", { button: 0 })
|
||||
await page.locator("#editor").evaluate((element: HTMLInputElement) => {
|
||||
element.disabled = false
|
||||
element.focus()
|
||||
})
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __navigationMilestones?: { samples: NavigationMilestoneSample[] } })
|
||||
.__navigationMilestones?.samples
|
||||
return samples?.some((sample) => sample.milestones.editor && !sample.milestones.model)
|
||||
})
|
||||
await page.locator("#model").evaluate((element) => {
|
||||
element.textContent = "Ready model"
|
||||
})
|
||||
},
|
||||
})
|
||||
expect(result.summary.all.firstObservedMs).not.toBeNull()
|
||||
expect(result.summary.all.firstObservedMs).toBeGreaterThan(result.summary.milestones.editor.firstObservedMs!)
|
||||
expect(await page.evaluate(() => "__navigationMilestones" in window)).toBe(false)
|
||||
report(result)
|
||||
},
|
||||
)
|
||||
@@ -36,7 +36,7 @@ export async function measureNavigationMilestones(
|
||||
page: Page,
|
||||
input: {
|
||||
triggerSelector: string
|
||||
milestones: Record<string, { selector: string; visible?: boolean; text?: string }>
|
||||
milestones: Record<string, { selector: string; visible?: boolean }>
|
||||
navigate: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
@@ -47,19 +47,11 @@ export async function measureNavigationMilestones(
|
||||
const marked = new Set<string>()
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const visible = (selector: string, text?: string) =>
|
||||
const visible = (selector: string) =>
|
||||
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
|
||||
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
|
||||
if (text !== undefined && element.textContent?.replace(/\s+/g, " ").trim() !== text) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > 0 &&
|
||||
rect.top < innerHeight &&
|
||||
rect.right > 0 &&
|
||||
rect.left < innerWidth
|
||||
)
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
||||
})
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
@@ -69,9 +61,7 @@ export async function measureNavigationMilestones(
|
||||
const current = Object.fromEntries(
|
||||
Object.entries(milestones).map(([name, milestone]) => [
|
||||
name,
|
||||
milestone.visible === false
|
||||
? !document.querySelector(milestone.selector)
|
||||
: visible(milestone.selector, milestone.text),
|
||||
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
|
||||
]),
|
||||
)
|
||||
samples.push({
|
||||
@@ -103,46 +93,36 @@ export async function measureNavigationMilestones(
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
const start = (event: MouseEvent) => {
|
||||
if (started !== undefined || event.button !== 0) return
|
||||
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
|
||||
started = performance.now()
|
||||
performance.mark("opencode.navigation.start")
|
||||
sample()
|
||||
}
|
||||
document.addEventListener("mousedown", start, true)
|
||||
document.addEventListener("click", start, true)
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
|
||||
started = performance.now()
|
||||
performance.mark("opencode.navigation.click")
|
||||
sample()
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("mousedown", start, true)
|
||||
document.removeEventListener("click", start, true)
|
||||
},
|
||||
}
|
||||
},
|
||||
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
|
||||
)
|
||||
try {
|
||||
await input.navigate()
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
|
||||
?.samples
|
||||
return (
|
||||
samples &&
|
||||
samples.length >= 3 &&
|
||||
samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
|
||||
)
|
||||
})
|
||||
const samples = await page.evaluate(
|
||||
() => (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!.samples,
|
||||
)
|
||||
return { summary: summarizeNavigationMilestones(samples), samples }
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
const host = window as Window & { __navigationMilestones?: NavigationMilestoneProbe }
|
||||
host.__navigationMilestones?.stop()
|
||||
delete host.__navigationMilestones
|
||||
})
|
||||
}
|
||||
await input.navigate()
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
|
||||
?.samples
|
||||
if (!samples || samples.length < 3) return false
|
||||
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
|
||||
})
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return { summary: summarizeNavigationMilestones(samples), samples }
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
|
||||
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
traceScope: "interaction",
|
||||
trace: "off",
|
||||
video: "off",
|
||||
})
|
||||
|
||||
for (const entry of ["home", "session"] as const) {
|
||||
benchmark(`entry: new session from ${entry}`, async ({ page, report }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page, { sessionIDs: entry === "home" ? [] : [fixture.sourceID] })
|
||||
await page.goto(entry === "home" ? "/" : stressSessionHref(fixture.sourceID))
|
||||
if (entry === "session") await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
const trigger = entry === "home" ? '[data-action="home-new-session"]' : 'button[aria-label="New session"]'
|
||||
await expect(page.locator(trigger)).toBeVisible()
|
||||
await expect(page.locator('[data-component="new-session"]')).toHaveCount(0)
|
||||
const writes: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.startsWith("/api/") && !["GET", "HEAD", "OPTIONS"].includes(request.method()))
|
||||
writes.push(request.method())
|
||||
})
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: trigger,
|
||||
milestones: {
|
||||
editor: {
|
||||
selector: '[data-component="new-session"] [data-component="composer-editor"][contenteditable="true"]:focus',
|
||||
},
|
||||
model: { selector: '[data-component="new-session"] [data-action="composer-model"]', text: "Claude Opus 4.6" },
|
||||
project: { selector: '[data-component="new-session"] [data-action="prompt-project"]' },
|
||||
tab: { selector: '[data-titlebar-tab-link][href^="/new-session?draftId="]' },
|
||||
},
|
||||
navigate: () => page.locator(trigger).click(),
|
||||
})
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
const editor = page.locator('[data-component="new-session"] [data-component="composer-editor"]')
|
||||
await expect(editor).toHaveText("")
|
||||
await page.keyboard.type("Draft input")
|
||||
await expect(editor).toHaveText("Draft input")
|
||||
expect(writes).toEqual([])
|
||||
report(
|
||||
{
|
||||
firstCorrectObservedMs: result.summary.all.firstObservedMs,
|
||||
stableObservedMs: result.summary.all.stableObservedMs,
|
||||
...result,
|
||||
},
|
||||
{ entry, data: "fixture", inputEvent: "mousedown" },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
benchmark("entry: cold session from Home", async ({ page, report }) => {
|
||||
const requests: string[] = []
|
||||
await mockStressTimeline(page, {
|
||||
onMessages: (request) => {
|
||||
if (request.phase === "start") requests.push(request.sessionID)
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: [] })
|
||||
await page.goto("/")
|
||||
const selector = `[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"] [data-component="home-session-row"]`
|
||||
await expect(page.locator(selector)).toBeVisible()
|
||||
expect(requests).not.toContain(fixture.targetID)
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
|
||||
sourceIDs: [],
|
||||
lastID: fixture.expected.targetMessageIDs.at(-1)!,
|
||||
requiredPartID: fixture.expected.targetPartIDs.at(-1)!,
|
||||
href,
|
||||
triggerSelector: selector,
|
||||
switch: async () => {
|
||||
await page.locator(selector).click()
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
},
|
||||
})
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
await expect(
|
||||
page.locator(`[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-link][href="${href}"]`),
|
||||
).toHaveCount(1)
|
||||
expect(requests).toContain(fixture.targetID)
|
||||
report(result, { entry: "home", data: "cold paginated fixture", inputEvent: "mousedown" })
|
||||
})
|
||||
@@ -1,129 +1,71 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { expected, messages, workload } from "./session-tab-switch.fixture"
|
||||
import {
|
||||
createReviewDiffs,
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const scenarios = [
|
||||
{ cache: "cold", review: "closed" },
|
||||
{ cache: "cold", review: "open" },
|
||||
{ cache: "warm", review: "closed" },
|
||||
{ cache: "warm", review: "open" },
|
||||
{ cache: "warm", review: "resized" },
|
||||
] as const
|
||||
|
||||
const viewport = { width: 1440, height: 900 }
|
||||
const reviewDiffs = createReviewDiffs()
|
||||
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
|
||||
{ cached: false, review: false, resized: false },
|
||||
{ cached: false, review: true, resized: false },
|
||||
{ cached: true, review: false, resized: false },
|
||||
{ cached: true, review: true, resized: false },
|
||||
{ cached: true, review: true, resized: true },
|
||||
]
|
||||
|
||||
scenarios.forEach((scenario) => {
|
||||
benchmark(`tab switch: ${scenario.cache}, review ${scenario.review}`, async ({ page, report }, testInfo) => {
|
||||
const requests = await prepareSessionTabs(page)
|
||||
if (scenario.review === "open") await openReviewPane(page)
|
||||
if (scenario.cache === "warm") {
|
||||
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await expectReadyTimeline(page, fixture.targetID)
|
||||
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
}
|
||||
if (scenario.review === "resized") await openReviewPane(page)
|
||||
await expectReadyTimeline(page, fixture.sourceID)
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const requestsBefore = requests.length
|
||||
const name = `tab switch: ${scenario.cached ? "cached" : "unmounted"}, review ${scenario.review ? "open" : "closed"}${scenario.resized ? ", resized" : ""}`
|
||||
benchmark(name, async ({ browser, report }, testInfo) => {
|
||||
const result = await withBenchmarkPage(
|
||||
browser,
|
||||
name,
|
||||
async (page) => {
|
||||
await mockStressTimeline(page, { vcsDiff: createReviewDiffs() })
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
if (scenario.review && !scenario.resized) await openReviewPane(page)
|
||||
if (scenario.cached) {
|
||||
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
const answer = page.locator(`[data-timeline-part-id="${fixture.expected.targetPartIDs.at(-1)}"]`)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect
|
||||
.poll(() =>
|
||||
answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })),
|
||||
)
|
||||
.toBe(true)
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
}
|
||||
if (scenario.resized) await openReviewPane(page)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: messages[fixture.targetID].map((message) => message.id),
|
||||
sourceIDs: messages[fixture.sourceID].map((message) => message.id),
|
||||
lastID: expected[fixture.targetID].lastID,
|
||||
requiredPartID: expected[fixture.targetID].answerID,
|
||||
href: stressSessionHref(fixture.targetID),
|
||||
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
|
||||
})
|
||||
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
expect(requests).toHaveLength(requestsBefore)
|
||||
await expectReadyTimeline(page, fixture.targetID)
|
||||
report(
|
||||
{
|
||||
...result,
|
||||
messageRequestsDuringSwitch: requests.length - requestsBefore,
|
||||
rendererMemory:
|
||||
process.env.OPENCODE_PERFORMANCE_MEMORY === "1" ? await retainedRendererMemory(page) : undefined,
|
||||
},
|
||||
{
|
||||
...scenario,
|
||||
...workload,
|
||||
viewport,
|
||||
browserVersion: page.context().browser()!.version(),
|
||||
serviceWorkers: "blocked",
|
||||
reviewFiles: scenario.review === "closed" ? 0 : reviewDiffs.length,
|
||||
data: "prefetched",
|
||||
transport: process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE === "1" ? "http" : "playwright-route",
|
||||
inputEvent: "mousedown",
|
||||
requireReadyAnswer: true,
|
||||
return measureSessionSwitch(page, {
|
||||
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
|
||||
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
|
||||
lastID: fixture.expected.targetMessageIDs.at(-1)!,
|
||||
requiredPartID: fixture.expected.targetPartIDs.at(-1),
|
||||
href: stressSessionHref(fixture.targetID),
|
||||
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
|
||||
})
|
||||
},
|
||||
testInfo,
|
||||
)
|
||||
if (testInfo.repeatEachIndex === 0) {
|
||||
await page.screenshot({ path: testInfo.outputPath("destination.png") })
|
||||
await testInfo.attach("destination", { path: testInfo.outputPath("destination.png"), contentType: "image/png" })
|
||||
}
|
||||
expect(result.unknownSamples).toBe(0)
|
||||
expect(result.wrongDestinationSamples).toBe(0)
|
||||
if (scenario.cached) expect(result.blankSamples).toBe(0)
|
||||
report(result, { ...scenario, inputEvent: "mousedown", requireReadyAnswer: true })
|
||||
})
|
||||
})
|
||||
|
||||
async function prepareSessionTabs(page: Page) {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "GET") return
|
||||
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (match) requests.push(decodeURIComponent(match[1]))
|
||||
})
|
||||
if (process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE !== "1")
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
// Return the full history so every scenario exercises a long loaded timeline.
|
||||
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
|
||||
vcsDiff: reviewDiffs,
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
// Restored tabs prefetch their data even when their transcript has never rendered.
|
||||
const prefetch = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
expect(await (await prefetch).finished()).toBeNull()
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await expectReadyTimeline(page, fixture.sourceID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${expected[fixture.targetID].answerID}"]`)).toHaveCount(0)
|
||||
expect(requests.toSorted()).toEqual([fixture.sourceID, fixture.targetID].toSorted())
|
||||
return requests
|
||||
}
|
||||
|
||||
async function expectReadyTimeline(page: Page, sessionID: string) {
|
||||
const answer = page.locator(`[data-timeline-part-id="${expected[sessionID].answerID}"]`)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(answer.getByRole("table")).toHaveCount(1)
|
||||
await expect(answer.locator("pre")).toHaveCount(4)
|
||||
await expect
|
||||
.poll(() => answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })))
|
||||
.toBe(true)
|
||||
await waitForStableTimeline(page, expected[sessionID].lastID)
|
||||
await expect(page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sessionID)}"]`)
|
||||
await expect(tab).toHaveCount(1)
|
||||
@@ -138,20 +80,4 @@ async function openReviewPane(page: Page) {
|
||||
const text = document.querySelector("#review-panel")?.textContent ?? ""
|
||||
return text.includes("generated-000.ts") && text.includes("+3")
|
||||
})
|
||||
await page.locator('[data-slot="session-chat-panel"]').evaluate(async (panel) => {
|
||||
await Promise.all(panel.getAnimations().map((animation) => animation.finished))
|
||||
})
|
||||
}
|
||||
|
||||
async function retainedRendererMemory(page: Page) {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
try {
|
||||
await cdp.send("HeapProfiler.collectGarbage")
|
||||
return {
|
||||
heap: await cdp.send("Runtime.getHeapUsage"),
|
||||
dom: await cdp.send("Memory.getDOMCounters"),
|
||||
}
|
||||
} finally {
|
||||
await cdp.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,77 +2,66 @@ import { benchmark, expect } from "../benchmark"
|
||||
import { measureSessionSwitch } from "./session-tab-switch-probe"
|
||||
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
|
||||
|
||||
for (const tag of ["a", "button"] as const) {
|
||||
benchmark(
|
||||
`starts at ${tag} mousedown and excludes hidden or unfinished destination content`,
|
||||
async ({ page, report }) => {
|
||||
await page.setContent(`
|
||||
<${tag} id="destination" ${tag === "a" ? 'href="/session/destination"' : 'type="button"'}>Destination</${tag}>
|
||||
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
|
||||
await page.setContent(`
|
||||
<a href="/session/destination">Destination</a>
|
||||
<div class="scroll-view__viewport" style="height:200px;overflow:auto">
|
||||
<div data-timeline-row="message" data-timeline-key="row" data-message-id="source">
|
||||
<div data-timeline-part-id="answer"><div data-component="markdown">Destination answer</div></div>
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
await page.evaluate(() => {
|
||||
document.querySelector("#destination")!.addEventListener("mousedown", () => {
|
||||
const row = document.querySelector<HTMLElement>("[data-message-id]")!
|
||||
row.dataset.messageId = "destination"
|
||||
row.style.visibility = "hidden"
|
||||
})
|
||||
await page.evaluate(() => {
|
||||
document.querySelector("a")!.addEventListener("mousedown", () => {
|
||||
const row = document.querySelector<HTMLElement>("[data-message-id]")!
|
||||
row.dataset.messageId = "destination"
|
||||
row.style.visibility = "hidden"
|
||||
})
|
||||
})
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: ["destination"],
|
||||
sourceIDs: ["source"],
|
||||
lastID: "destination",
|
||||
requiredPartID: "answer",
|
||||
requireBottomAnchor: false,
|
||||
href: "/session/destination",
|
||||
switch: async () => {
|
||||
// No click is dispatched: the probe must observe the event that activates tabs.
|
||||
await page.getByRole("link", { name: "Destination" }).dispatchEvent("mousedown", { button: 0 })
|
||||
await page.waitForFunction(() => {
|
||||
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
|
||||
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
|
||||
})
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: ["destination"],
|
||||
sourceIDs: ["source"],
|
||||
lastID: "destination",
|
||||
requiredPartID: "answer",
|
||||
requireBottomAnchor: false,
|
||||
href: "/session/destination",
|
||||
triggerSelector: tag === "button" ? "#destination" : undefined,
|
||||
switch: async () => {
|
||||
// No click is dispatched: the probe must observe the event that activates tabs.
|
||||
await page
|
||||
.getByRole(tag === "a" ? "link" : "button", { name: "Destination", exact: true })
|
||||
.dispatchEvent("mousedown", { button: 0 })
|
||||
await page.waitForFunction(() => {
|
||||
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
|
||||
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
|
||||
})
|
||||
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
|
||||
await page.waitForFunction(() => {
|
||||
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
|
||||
return host.__sessionSwitchProbe?.samples.some(
|
||||
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
|
||||
)
|
||||
})
|
||||
const beforeClip = await page.evaluate(() => {
|
||||
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
|
||||
row.style.cssText = "height:10px;position:relative;overflow:clip"
|
||||
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
|
||||
answer.style.cssText = "position:absolute;top:30px;width:150px"
|
||||
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
|
||||
return (
|
||||
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
|
||||
?.samples.length ?? 0
|
||||
)
|
||||
})
|
||||
await page.waitForFunction((count) => {
|
||||
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
|
||||
return host.__sessionSwitchProbe?.samples
|
||||
.slice(count)
|
||||
.some((sample) => sample.requiredPartVisible === false)
|
||||
}, beforeClip)
|
||||
await page.locator("[data-timeline-key]").evaluate((row) => {
|
||||
row.style.height = "100px"
|
||||
})
|
||||
},
|
||||
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
|
||||
await page.waitForFunction(() => {
|
||||
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
|
||||
return host.__sessionSwitchProbe?.samples.some(
|
||||
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
|
||||
)
|
||||
})
|
||||
const beforeClip = await page.evaluate(() => {
|
||||
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
|
||||
row.style.cssText = "height:10px;position:relative;overflow:clip"
|
||||
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
|
||||
answer.style.cssText = "position:absolute;top:30px;width:150px"
|
||||
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
|
||||
return (
|
||||
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
|
||||
?.samples.length ?? 0
|
||||
)
|
||||
})
|
||||
await page.waitForFunction((count) => {
|
||||
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
|
||||
return host.__sessionSwitchProbe?.samples.slice(count).some((sample) => sample.requiredPartVisible === false)
|
||||
}, beforeClip)
|
||||
await page.locator("[data-timeline-key]").evaluate((row) => {
|
||||
row.style.height = "100px"
|
||||
})
|
||||
expect(result.blankSamples).toBeGreaterThan(0)
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
|
||||
expect(await page.evaluate(() => "__sessionSwitchProbe" in window)).toBe(false)
|
||||
report(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
expect(result.blankSamples).toBeGreaterThan(0)
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
|
||||
report(result)
|
||||
})
|
||||
|
||||
@@ -14,132 +14,124 @@ async function installSessionSwitchProbe(
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
triggerSelector?: string
|
||||
href: string
|
||||
},
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, triggerSelector, href }) => {
|
||||
const destination = new Set(destinationIDs)
|
||||
const source = new Set(sourceIDs)
|
||||
const samples: SessionSwitchSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const reviewLevels: Record<string, string> = {
|
||||
panel: "#review-panel",
|
||||
tabs: '#review-panel [data-component="tabs"]',
|
||||
body: '#review-panel [data-slot="session-review-v2-body"]',
|
||||
review: '#review-panel [data-component="session-review-v2"]',
|
||||
preview: '#review-panel [data-slot="session-review-v2-preview"]',
|
||||
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
|
||||
file: '#review-panel [data-component="file"][data-mode="diff"]',
|
||||
}
|
||||
const initialReviewNodes: Record<string, Element | null> = {}
|
||||
const sample = () => {
|
||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
|
||||
const destination = new Set(destinationIDs)
|
||||
const source = new Set(sourceIDs)
|
||||
const samples: SessionSwitchSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const reviewLevels: Record<string, string> = {
|
||||
panel: "#review-panel",
|
||||
tabs: '#review-panel [data-component="tabs"]',
|
||||
body: '#review-panel [data-slot="session-review-v2-body"]',
|
||||
review: '#review-panel [data-component="session-review-v2"]',
|
||||
preview: '#review-panel [data-slot="session-review-v2-preview"]',
|
||||
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
|
||||
file: '#review-panel [data-component="file"][data-mode="diff"]',
|
||||
}
|
||||
const initialReviewNodes: Record<string, Element | null> = {}
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
|
||||
const initialReviewFile = initialReviewNodes.file
|
||||
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
|
||||
const initial = initialReviewNodes[name]
|
||||
if (!initial) return []
|
||||
const current = document.querySelector(selector)
|
||||
return current && current !== initial ? [name] : []
|
||||
})
|
||||
const review = reviewPanel
|
||||
? {
|
||||
fileHost: !!reviewFile,
|
||||
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
|
||||
header:
|
||||
reviewPanel
|
||||
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
|
||||
?.textContent?.trim() ?? "",
|
||||
replacedLevels,
|
||||
}
|
||||
: undefined
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (root) {
|
||||
const view = root.getBoundingClientRect()
|
||||
const inViewport = (element: HTMLElement) => {
|
||||
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
|
||||
return (
|
||||
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
|
||||
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
|
||||
)
|
||||
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
|
||||
const initialReviewFile = initialReviewNodes.file
|
||||
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
|
||||
const initial = initialReviewNodes[name]
|
||||
if (!initial) return []
|
||||
const current = document.querySelector(selector)
|
||||
return current && current !== initial ? [name] : []
|
||||
})
|
||||
const review = reviewPanel
|
||||
? {
|
||||
fileHost: !!reviewFile,
|
||||
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
|
||||
header:
|
||||
reviewPanel
|
||||
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
|
||||
?.textContent?.trim() ?? "",
|
||||
replacedLevels,
|
||||
}
|
||||
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.filter(inViewport)
|
||||
.map((element) => element.dataset.messageId!)
|
||||
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
|
||||
const requiredPartVisible = requiredPartID
|
||||
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
|
||||
if (element.dataset.timelinePartId !== requiredPartID) return false
|
||||
if (!element.textContent?.trim()) return false
|
||||
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
|
||||
return inViewport(element)
|
||||
})
|
||||
: undefined
|
||||
const spacer = root
|
||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||
?.getBoundingClientRect()
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
destination: visible.filter((id) => destination.has(id)),
|
||||
source: visible.filter((id) => source.has(id)),
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
requiredPartVisible,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
review,
|
||||
})
|
||||
} else {
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
destination: [],
|
||||
source: [],
|
||||
hasVisibleRows: false,
|
||||
last: false,
|
||||
requiredPartVisible: requiredPartID ? false : undefined,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
review,
|
||||
})
|
||||
: undefined
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (root) {
|
||||
const view = root.getBoundingClientRect()
|
||||
const inViewport = (element: HTMLElement) => {
|
||||
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
|
||||
return (
|
||||
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
|
||||
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
|
||||
)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
const start = (event: MouseEvent) => {
|
||||
if (started !== undefined || event.button !== 0) return
|
||||
const trigger = event.target instanceof Element ? event.target.closest(triggerSelector ?? "a") : undefined
|
||||
if (!trigger || (!triggerSelector && trigger.getAttribute("href") !== href)) return
|
||||
started = performance.now()
|
||||
performance.mark("session-switch:start", { startTime: started })
|
||||
for (const [name, selector] of Object.entries(reviewLevels)) {
|
||||
initialReviewNodes[name] = document.querySelector(selector)
|
||||
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.filter(inViewport)
|
||||
.map((element) => element.dataset.messageId!)
|
||||
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
|
||||
const requiredPartVisible = requiredPartID
|
||||
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
|
||||
if (element.dataset.timelinePartId !== requiredPartID) return false
|
||||
if (!element.textContent?.trim()) return false
|
||||
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
|
||||
return inViewport(element)
|
||||
})
|
||||
: undefined
|
||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
destination: visible.filter((id) => destination.has(id)),
|
||||
source: visible.filter((id) => source.has(id)),
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
requiredPartVisible,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
review,
|
||||
})
|
||||
} else {
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
destination: [],
|
||||
source: [],
|
||||
hasVisibleRows: false,
|
||||
last: false,
|
||||
requiredPartVisible: requiredPartID ? false : undefined,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
review,
|
||||
})
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
const start = (event: MouseEvent) => {
|
||||
if (started !== undefined || event.button !== 0) return
|
||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (link?.getAttribute("href") !== href) return
|
||||
started = performance.now()
|
||||
for (const [name, selector] of Object.entries(reviewLevels)) {
|
||||
initialReviewNodes[name] = document.querySelector(selector)
|
||||
}
|
||||
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
|
||||
document.addEventListener("mousedown", start, true)
|
||||
document.addEventListener("click", start, true)
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("mousedown", start, true)
|
||||
document.removeEventListener("click", start, true)
|
||||
Object.keys(initialReviewNodes).forEach((key) => (initialReviewNodes[key] = null))
|
||||
},
|
||||
}
|
||||
},
|
||||
input,
|
||||
)
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
|
||||
document.addEventListener("mousedown", start, true)
|
||||
document.addEventListener("click", start, true)
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("mousedown", start, true)
|
||||
document.removeEventListener("click", start, true)
|
||||
},
|
||||
}
|
||||
}, input)
|
||||
}
|
||||
|
||||
async function waitForStableSessionSwitch(page: Page) {
|
||||
@@ -167,17 +159,9 @@ async function collectSessionSwitchResult(page: Page) {
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
|
||||
probe.stop()
|
||||
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
|
||||
return probe.samples
|
||||
})
|
||||
const result = classifySessionSwitch(samples)
|
||||
await page.evaluate(({ firstCorrectObservedMs, stableObservedMs }) => {
|
||||
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
|
||||
if (firstCorrectObservedMs !== null)
|
||||
performance.mark("session-switch:ready", { startTime: start + firstCorrectObservedMs })
|
||||
if (stableObservedMs !== null) performance.mark("session-switch:stable", { startTime: start + stableObservedMs })
|
||||
}, result)
|
||||
return result
|
||||
return classifySessionSwitch(samples)
|
||||
}
|
||||
|
||||
export async function measureSessionSwitch(
|
||||
@@ -188,7 +172,6 @@ export async function measureSessionSwitch(
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
triggerSelector?: string
|
||||
href: string
|
||||
switch: () => Promise<void>
|
||||
},
|
||||
@@ -202,7 +185,6 @@ export async function measureSessionSwitch(
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
|
||||
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
|
||||
export const exchanges = 200
|
||||
|
||||
export const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((sessionID) => [
|
||||
sessionID,
|
||||
Array.from({ length: exchanges }, (_, index) => {
|
||||
const seed = fixture.messages[fixture.targetID]
|
||||
const user = seed[(index % (seed.length / 2)) * 2]!
|
||||
const assistant = seed[(index % (seed.length / 2)) * 2 + 1]!
|
||||
if (user.type !== "user" || assistant.type !== "assistant") throw new Error("Expected a user/assistant pair")
|
||||
const suffix = `${sessionID}_${String(index).padStart(4, "0")}`
|
||||
return [
|
||||
{
|
||||
...user,
|
||||
id: `msg_user_${suffix}`,
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
},
|
||||
{
|
||||
...assistant,
|
||||
id: `msg_assistant_${suffix}`,
|
||||
time: { created: 1700000001000 + index * 10_000, completed: 1700000008000 + index * 10_000 },
|
||||
content: [
|
||||
...assistant.content
|
||||
.filter((part) => part.type !== "text")
|
||||
.map((part) => (part.type === "tool" ? { ...part, id: `${part.id}_${suffix}` } : part)),
|
||||
{ type: "text", text: complexMarkdown(sessionID, index) },
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
}).flat(),
|
||||
]),
|
||||
)
|
||||
|
||||
export const expected = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((sessionID) => [
|
||||
sessionID,
|
||||
{
|
||||
lastID: messages[sessionID].at(-2)!.id,
|
||||
answerID: `${messages[sessionID].at(-1)!.id}:text:0`,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
export const workload = {
|
||||
fixture: "long-complex-markdown-v1",
|
||||
exchangesPerSession: exchanges,
|
||||
messagesPerSession: exchanges * 2,
|
||||
history: "full fixture history in one response",
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(messages).map(([sessionID, items]) => [
|
||||
sessionID,
|
||||
{
|
||||
payloadBytes: Buffer.byteLength(JSON.stringify(items)),
|
||||
markdownBytes: items.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
(message.type === "assistant"
|
||||
? message.content.reduce(
|
||||
(size, part) => size + (part.type === "text" ? Buffer.byteLength(part.text) : 0),
|
||||
0,
|
||||
)
|
||||
: 0),
|
||||
0,
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}
|
||||
|
||||
function complexMarkdown(sessionID: string, index: number) {
|
||||
return `## Renderer review ${sessionID} / ${index}
|
||||
|
||||
Preserve **semantic identity**, *measured geometry*, and ~~obsolete estimates~~ when switching sessions. The \`measureElement(node)\` result must agree with the [rendering contract](https://example.com/rendering/${sessionID}/${index}).
|
||||
|
||||
> A completed answer contains formatted prose, highlighted source, and structured results.
|
||||
> Keep the previous view until the destination is ready, rather than exposing partially formatted content.
|
||||
|
||||
### Readiness checklist
|
||||
|
||||
- [x] Resolve the destination session and its messages.
|
||||
- [x] Parse Markdown and highlight fenced code.
|
||||
- [ ] Verify a different panel width.
|
||||
- Preserve the bottom anchor.
|
||||
- Reuse the measured rows when their width matches.
|
||||
|
||||
| Stage | Input | Expected result | Verification |
|
||||
| :--- | ---: | :--- | :--- |
|
||||
${Array.from({ length: 8 }, (_, row) => `| stage-${index}-${row} | ${index * 8 + row} | **ready** with \`row[${row}]\` | stable geometry and visible content |`).join("\n")}
|
||||
|
||||
### Implementation
|
||||
|
||||
\`\`\`tsx
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
|
||||
type Row = { id: string; title: string; ready: boolean; height: number }
|
||||
|
||||
export function SessionRows${index}(props: { rows: Row[]; selected: string }) {
|
||||
const visible = createMemo(() => props.rows.filter((row) => row.ready))
|
||||
return (
|
||||
<section aria-label="${sessionID}-${index}">
|
||||
<For each={visible()}>{(row) => (
|
||||
<article data-selected={row.id === props.selected}>
|
||||
<h3>{row.title}</h3>
|
||||
<Show when={row.height > 0} fallback={<span>Measuring</span>}>
|
||||
<output>{row.height.toFixed(2)} pixels</output>
|
||||
</Show>
|
||||
</article>
|
||||
)}</For>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify({ session: sessionID, exchange: index, stages: ["hydrate", "parse", "highlight", "measure"], viewport: { width: 1440, height: 900 }, cache: { markdown: true, geometry: true } }, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`sql
|
||||
SELECT session_id, COUNT(*) AS messages, MAX(created_at) AS latest
|
||||
FROM session_message
|
||||
WHERE session_id = '${sessionID}' AND ordinal >= ${index}
|
||||
GROUP BY session_id
|
||||
ORDER BY latest DESC;
|
||||
\`\`\`
|
||||
|
||||
### Verification
|
||||
|
||||
1. Open the long source session and wait for its final answer.
|
||||
2. Select the destination tab, without changing the viewport.
|
||||
3. Confirm that **all Markdown is ready** and the bottom anchor is correct.
|
||||
|
||||
\`\`\`bash
|
||||
bun typecheck
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts
|
||||
git diff --check # ${sessionID}-${index}
|
||||
\`\`\`
|
||||
|
||||
**Review complete: ${sessionID} / ${index}.**
|
||||
`
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import type { FullConfig, Suite, TestCase, TestResult } from "@playwright/test/reporter"
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import TabSwitchReporter from "../tab-switch-reporter"
|
||||
|
||||
test("summarizes each scenario and saves complete records in the configured output directory", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
|
||||
const output = path.join(root, "configured-output")
|
||||
const log = spyOn(console, "log").mockImplementation(() => {})
|
||||
try {
|
||||
const reporter = new TabSwitchReporter()
|
||||
const cases = Array.from(
|
||||
{ length: 23 },
|
||||
(_, index) => ({ id: String(index), title: index < 20 ? "cold" : "warm" }) as TestCase,
|
||||
)
|
||||
const records = cases.map((_, index) => {
|
||||
const first = index < 20 ? 20 - index : [40, 0, 20][index - 20]
|
||||
return JSON.stringify({
|
||||
status: "passed",
|
||||
metrics: {
|
||||
firstCorrectObservedMs: first,
|
||||
stableObservedMs: first * 2,
|
||||
samples: [{ observedAtMs: first, destination: ["answer"], source: [] }],
|
||||
},
|
||||
extra: { preserved: "\u03b1" },
|
||||
})
|
||||
})
|
||||
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
|
||||
cases.forEach((item, index) => {
|
||||
const bytes = Buffer.from(`BENCHMARK ${records[index]}\r\n`)
|
||||
const split = bytes.indexOf(Buffer.from("\u03b1")) + 1
|
||||
reporter.onTestEnd(item, {
|
||||
status: "passed",
|
||||
stdout:
|
||||
index === 0
|
||||
? [bytes.subarray(0, split), bytes.subarray(split)]
|
||||
: ["other output\nBENCHMARK_PAGE {}\nBENCH", "MARK ", records[index], "\n"],
|
||||
} as TestResult)
|
||||
})
|
||||
await reporter.onEnd({ status: "passed", startTime: new Date(0), duration: 0 })
|
||||
|
||||
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(`${records.join("\n")}\n`)
|
||||
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
|
||||
expect(summary).toContain("cold\n Tests: passed=20; unrun=0")
|
||||
expect(summary).toContain("warm\n Tests: passed=3; unrun=0")
|
||||
expect(summary).toContain("firstCorrectObservedMs: n=20, median=10.50 ms, p95=19.00 ms")
|
||||
expect(summary).toContain("stableObservedMs: n=20, median=21.00 ms, p95=38.00 ms")
|
||||
expect(summary).toContain("firstCorrectObservedMs: n=3, median=20.00 ms, p95=40.00 ms")
|
||||
expect(summary).toContain("stableObservedMs: n=3, median=40.00 ms, p95=80.00 ms")
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("reports failures, missing records, and invalid metrics without discarding raw data", async () => {
|
||||
const output = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
|
||||
const log = spyOn(console, "log").mockImplementation(() => {})
|
||||
try {
|
||||
const reporter = new TabSwitchReporter()
|
||||
const entries = [
|
||||
{
|
||||
status: "passed",
|
||||
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":12,"stableObservedMs":24}}',
|
||||
},
|
||||
{
|
||||
status: "failed",
|
||||
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
|
||||
},
|
||||
{
|
||||
status: "passed",
|
||||
raw: '{"status":"failed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
|
||||
},
|
||||
{ status: "timedOut", raw: '{"status":"failed","metrics":null,"error":"Benchmark did not report metrics"}' },
|
||||
{
|
||||
status: "passed",
|
||||
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":null,"stableObservedMs":40}}',
|
||||
},
|
||||
{ status: "failed", raw: '{"status":' },
|
||||
{ status: "skipped", raw: undefined },
|
||||
] as const
|
||||
const cases = Array.from(
|
||||
{ length: entries.length + 2 },
|
||||
(_, index) => ({ id: String(index), title: index <= entries.length ? "cold" : "empty" }) as TestCase,
|
||||
)
|
||||
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
|
||||
entries.forEach((entry, index) => {
|
||||
reporter.onTestEnd(cases[index], {
|
||||
status: entry.status,
|
||||
stdout: entry.raw === undefined ? [] : [`BENCHMARK ${entry.raw}\n`],
|
||||
} as TestResult)
|
||||
})
|
||||
await reporter.onEnd({ status: "interrupted", startTime: new Date(0), duration: 0 })
|
||||
|
||||
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(
|
||||
entries.flatMap((entry) => (entry.raw === undefined ? [] : [`${entry.raw}\n`])).join(""),
|
||||
)
|
||||
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
|
||||
expect(summary).toContain("Tab-switch benchmark: interrupted")
|
||||
expect(summary).toContain("Tests: passed=3, failed=2, timedOut=1, skipped=1; unrun=1")
|
||||
expect(summary).toContain("Records: passed=3, failed=2, invalid JSON=1; missing=2; excluded=5; invalid metrics=1")
|
||||
expect(summary).toContain("firstCorrectObservedMs: n=1, median=12.00 ms, p95=12.00 ms")
|
||||
expect(summary).toContain("stableObservedMs: n=1, median=24.00 ms, p95=24.00 ms")
|
||||
expect(summary).toContain("empty\n Tests: none; unrun=1")
|
||||
expect(summary).toContain("Records: none; missing=1; excluded=0; invalid metrics=0")
|
||||
expect(summary).toContain("firstCorrectObservedMs: n=0, median=n/a, p95=n/a")
|
||||
expect(summary).toContain("stableObservedMs: n=0, median=n/a, p95=n/a")
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
await rm(output, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
for (const window of ["assistant-only", "mixed"] as const) {
|
||||
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
|
||||
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
|
||||
// Both 20-message pages begin with an assistant; only page three supplies its parent.
|
||||
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
|
||||
const id = `msg_hydration_${index}`
|
||||
const time = { created: 1700000000000 + index * 1_000 }
|
||||
if (index === 0 || (window === "mixed" && index === 39))
|
||||
return { id, type: "user", time, text: `Prompt ${index}` }
|
||||
return {
|
||||
id,
|
||||
type: "assistant",
|
||||
time: { ...time, completed: time.created + 500 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
}
|
||||
})
|
||||
const gates = [21, 1].map((index) => ({
|
||||
before: messages[index]!.id,
|
||||
parent: messages[index === 21 ? 1 : 0]!.id,
|
||||
requested: Promise.withResolvers<void>(),
|
||||
release: Promise.withResolvers<void>(),
|
||||
}))
|
||||
const requests: (string | undefined)[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions: [session],
|
||||
beforeMessagesResponse: async ({ before }) => {
|
||||
requests.push(before)
|
||||
if (!before) return
|
||||
const gate = gates.find((gate) => gate.before === before)
|
||||
if (!gate) throw new Error(`Unexpected older-page boundary: ${before}`)
|
||||
gate.requested.resolve()
|
||||
await gate.release.promise
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
expect(limit).toBe(20)
|
||||
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
|
||||
},
|
||||
})
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
|
||||
const markdown = tail.locator('[data-component="markdown"]')
|
||||
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: tail })
|
||||
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
|
||||
})
|
||||
const expectReadyTail = async () => {
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(markdown.getByRole("heading", { name: "Hydrated tail", exact: true })).toBeInViewport({ ratio: 1 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
viewport.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(stressSessionHref(session.id))
|
||||
await gates[0]!.requested.promise
|
||||
// This must pass while the first older response is still held.
|
||||
await expectReadyTail()
|
||||
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
|
||||
if (window === "mixed")
|
||||
await expect(
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
|
||||
).toBeInViewport()
|
||||
const original = await markdown.elementHandle()
|
||||
|
||||
for (const gate of gates) {
|
||||
await gate.requested.promise
|
||||
gate.release.resolve()
|
||||
// Parent ownership proves the page reached the projection, not just the network.
|
||||
await expect(orphan).toHaveAttribute("data-message-id", gate.parent)
|
||||
await expectReadyTail()
|
||||
expect(await markdown.evaluate((element, original) => element === original, original)).toBe(true)
|
||||
}
|
||||
expect(requests).toEqual([undefined, ...gates.map((gate) => gate.before)])
|
||||
const ids = await content
|
||||
.locator("[data-timeline-part-id]")
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-part-id")))
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
} finally {
|
||||
gates.forEach((gate) => gate.release.resolve())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { expected, messages } from "../performance/timeline/session-tab-switch.fixture"
|
||||
import { installTimelineSettings, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
type Reveal = { pending: number; clipped: string[]; bottomError: number; tables: number; codeBlocks: number }
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`reveals measured Markdown after the worker completes at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(/markdown\.worker(?:-[^/?]+\.js|\.ts)(?:\?.*)?$/, async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.continue()
|
||||
})
|
||||
await page.addInitScript((partID) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const answer = document.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||
const content = answer?.closest<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const root = content?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
if (!answer || !content || !root || !content.checkVisibility({ checkVisibilityCSS: true })) return
|
||||
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
|
||||
;(window as Window & { __coldReveal?: Reveal }).__coldReveal = {
|
||||
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
|
||||
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
|
||||
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
|
||||
? [row.dataset.timelineKey!]
|
||||
: [],
|
||||
),
|
||||
bottomError: (spacer?.getBoundingClientRect().bottom ?? Infinity) - root.getBoundingClientRect().bottom,
|
||||
tables: answer.querySelectorAll("table").length,
|
||||
codeBlocks: answer.querySelectorAll("pre").length,
|
||||
}
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] })
|
||||
}, expected[fixture.sourceID].answerID)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
pageMessages: () => ({ items: messages[fixture.sourceID] }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
try {
|
||||
await page.goto(stressSessionHref(fixture.sourceID), { waitUntil: "domcontentloaded" })
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
|
||||
release.resolve()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
const reveal = await page.evaluate(() => (window as Window & { __coldReveal?: Reveal }).__coldReveal)
|
||||
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
|
||||
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("scrolls within a long answer without mounting unrelated history", async ({ page }) => {
|
||||
await openTimeline(page, messages[fixture.sourceID])
|
||||
const answer = page.locator(`[data-timeline-part-id="${expected[fixture.sourceID].answerID}"]`)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(answer.getByRole("table")).toHaveCount(1)
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: answer })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
const rows = page.locator("[data-timeline-key]")
|
||||
const keys = await rows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-timeline-key")),
|
||||
)
|
||||
const top = await answer.evaluate((element) => element.getBoundingClientRect().top)
|
||||
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, -240)
|
||||
|
||||
await expect.poll(() => answer.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top + 240, 0)
|
||||
expect(
|
||||
await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-key"))),
|
||||
).toEqual(keys)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
})
|
||||
|
||||
test("fills a short cold transcript before revealing it", async ({ page }) => {
|
||||
const history = messages[fixture.sourceID].slice(-6).map((message, index) => {
|
||||
if (message.type === "user") return { ...message, text: `Prompt ${index}`, metadata: undefined }
|
||||
if (message.type === "assistant")
|
||||
return { ...message, content: [{ type: "text" as const, text: `**Answer ${index}**` }] }
|
||||
return message
|
||||
})
|
||||
await openTimeline(page, history)
|
||||
for (const message of history) {
|
||||
if (message.type === "user") {
|
||||
await expect(page.locator(`[data-timeline-row="UserMessage"][data-message-id="${message.id}"]`)).toBeInViewport()
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
const answer = page.locator(`[data-timeline-part-id="${message.id}:text:0"]`)
|
||||
await expect(answer).toBeInViewport()
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function openTimeline(page: Page, history: SessionMessageInfo[]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
pageMessages: () => ({ items: history }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
@@ -102,14 +102,6 @@ test("cramped tabs only show the close button for the active tab", async ({ page
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
await page.setViewportSize({ width: 450, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
|
||||
}
|
||||
})
|
||||
|
||||
test("vertical tabs show project details, resize, and navigate", async ({ page }) => {
|
||||
@@ -296,12 +288,11 @@ async function mockServer(page: Page) {
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/inbox`)) return json(route, { data: [] })
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
|
||||
@@ -20,134 +20,95 @@ const inventory: WorktreeDirectory[] = [
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test.describe(theme, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((theme) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", theme)
|
||||
}, theme)
|
||||
})
|
||||
for (const scenario of [
|
||||
{ name: "managed Git worktree", directory: workspace, accent: true },
|
||||
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
|
||||
{
|
||||
name: "linked Git worktree on a narrow screen",
|
||||
directory: "C:/OpenCode/LinkedWorkspace",
|
||||
accent: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
{ name: "main root with Windows case and separators", directory: "c:\\OPENCODE\\workspaceaccent\\", accent: false },
|
||||
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
|
||||
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
|
||||
{
|
||||
name: "workspace with Windows case and separators",
|
||||
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
|
||||
accent: true,
|
||||
},
|
||||
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
|
||||
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
|
||||
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
|
||||
]) {
|
||||
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
|
||||
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
|
||||
const view = await openSession(page, scenario.directory)
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
for (const scenario of [
|
||||
{ name: "managed Git worktree", directory: workspace, accent: true },
|
||||
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
|
||||
{
|
||||
name: "linked Git worktree on a narrow screen",
|
||||
directory: "C:/OpenCode/LinkedWorkspace",
|
||||
accent: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
{
|
||||
name: "main root with Windows case and separators",
|
||||
directory: "c:\\OPENCODE\\workspaceaccent\\",
|
||||
accent: false,
|
||||
},
|
||||
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
|
||||
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
|
||||
{
|
||||
name: "workspace with Windows case and separators",
|
||||
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
|
||||
accent: true,
|
||||
},
|
||||
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
|
||||
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
|
||||
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
|
||||
]) {
|
||||
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
|
||||
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
|
||||
const view = await openSession(page, scenario.directory)
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
if (scenario.name === "managed Git worktree") {
|
||||
// Capture before the color assertion so both red and green runs have evidence.
|
||||
const path = testInfo.outputPath("workspace-accent.png")
|
||||
await view.composer.screenshot({ path })
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
}
|
||||
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(
|
||||
message,
|
||||
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
|
||||
"background-color",
|
||||
)
|
||||
})
|
||||
if (scenario.name === "managed Git worktree") {
|
||||
// Capture before the color assertion so both red and green runs have evidence.
|
||||
const path = testInfo.outputPath("workspace-accent.png")
|
||||
await view.composer.screenshot({ path })
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
}
|
||||
|
||||
test("inventory updates leave send neutral; disabled and stop stay neutral", async ({ page }) => {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_inventory",
|
||||
created: 1700000001000,
|
||||
type: "worktree.updated",
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
created: 1700000002000,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
test("new workspace send button stays neutral", async ({ page }) => {
|
||||
const view = await openSession(page, root, [...inventory], true)
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
})
|
||||
await expectBackground(view.send, scenario.accent ? "accent" : "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(message, scenario.accent ? "accent" : "layer-02", "background-color")
|
||||
})
|
||||
}
|
||||
|
||||
async function openSession(page: Page, directory: string, worktrees = [...inventory], draft = false) {
|
||||
test("inventory updates recolor the send button without navigation; disabled and stop stay neutral", async ({
|
||||
page,
|
||||
}) => {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_inventory",
|
||||
created: 1700000001000,
|
||||
type: "worktree.updated",
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "accent")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
created: 1700000002000,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "accent")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
async function openSession(page: Page, directory: string, worktrees = [...inventory]) {
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -198,32 +159,18 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
if (draft)
|
||||
await page.addInitScript(
|
||||
({ root, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: root, expanded: true }] },
|
||||
lastProject: { local: root },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID: "draft_workspace_accent", server, directory: root }]),
|
||||
)
|
||||
},
|
||||
{ root, server },
|
||||
)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "light")
|
||||
})
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(
|
||||
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
expect((await loaded).ok()).toBe(true)
|
||||
if (!draft) await expectSessionReady(page, { server, sessionID, title })
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light")
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
await expectAppVisible(composer)
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
|
||||
@@ -355,14 +355,6 @@ test.describe("smoke: session timeline", () => {
|
||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
|
||||
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
||||
// The shell is below a long diff; reveal it rather than depending on offscreen overscan.
|
||||
while ((await shell.count()) === 0) {
|
||||
const before = await timelineState(page)
|
||||
await timelineScroller(page).press("PageDown")
|
||||
await expect.poll(async () => (await timelineState(page)).signature).not.toBe(before.signature)
|
||||
}
|
||||
await shell.scrollIntoViewIfNeeded()
|
||||
await expect(shell).toBeInViewport()
|
||||
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
@@ -702,7 +694,6 @@ async function expectSessionTimelineReady(
|
||||
expectedMessageIDs: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
await waitForTimelineStable(page)
|
||||
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
|
||||
const currentState = await timelineState(page)
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/NewProject"
|
||||
|
||||
test("creates a session in a new project and selects its model", async ({ page }) => {
|
||||
// An empty draft must remain usable when the file viewer is unavailable.
|
||||
await page.route(/(?:\/_assets\/file-(?!icon-)[^/]+\.js|\/session-ui\/src\/components\/file\.tsx)(?:\?|$)/, (route) =>
|
||||
route.abort(),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -66,7 +60,7 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
|
||||
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
|
||||
],
|
||||
recent: [{ providerID: "opencode-go", modelID: "go-model-1" }],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
@@ -87,73 +81,11 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
await expectAppVisible(page.locator('[data-component="composer"]'))
|
||||
|
||||
const modelControl = page.locator('[data-action="composer-model"]')
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
await modelControl.click()
|
||||
await page.locator('[data-option-key="opencode:free-model"]').click()
|
||||
await expect(modelControl).toContainText("Free Model")
|
||||
|
||||
await modelControl.click()
|
||||
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
|
||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||
await expect(goModel).toBeVisible()
|
||||
await goModel.click()
|
||||
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
})
|
||||
|
||||
test("restores each existing session's model and variant when switching tabs", async ({ page }) => {
|
||||
const sessions = ["A", "B"].map((name) => ({
|
||||
...fixture.sessions[0],
|
||||
id: `ses_model_${name}`,
|
||||
title: `Model ${name}`,
|
||||
model: { id: `model-${name}`, providerID: "opencode", variant: "balanced" },
|
||||
}))
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: Object.fromEntries(
|
||||
sessions.map((session) => [
|
||||
session.model.id,
|
||||
{
|
||||
id: session.model.id,
|
||||
name: session.title,
|
||||
limit: { context: 200_000 },
|
||||
variants: { balanced: {}, high: {} },
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: sessions[0]!.model.id },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
|
||||
const hrefA = stressSessionHref(sessions[0]!.id)
|
||||
const hrefB = stressSessionHref(sessions[1]!.id)
|
||||
await page.goto(hrefA)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const modelControl = composer.locator('[data-action="composer-model"]')
|
||||
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
|
||||
await expect(modelControl).toHaveText("Model A")
|
||||
await expect(variant).toHaveText("balanced")
|
||||
await variant.click()
|
||||
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
|
||||
await expect(variant).toHaveText("high")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).click()
|
||||
await expect(page).toHaveURL(hrefB)
|
||||
await expect(modelControl).toHaveText("Model B")
|
||||
await expect(variant).toHaveText("balanced")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${hrefA}"]`).click()
|
||||
await expect(page).toHaveURL(hrefA)
|
||||
await expect(modelControl).toHaveText("Model A")
|
||||
await expect(variant).toHaveText("high")
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ type MockStreamWindow = Window & {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
await page.addInitScript(
|
||||
@@ -134,7 +135,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}, 50)
|
||||
page.on("close", () => clearInterval(timer))
|
||||
}
|
||||
const transport = createMockServerHandler(config)
|
||||
const transport = HttpRouter.toWebHandler(
|
||||
HttpApiBuilder.layer(MockApi).pipe(
|
||||
Layer.provide(mockHandlers(config, state)),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
)
|
||||
page.on("close", () => void transport.dispose())
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
@@ -166,16 +173,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
export function createMockServerHandler(config: MockServerConfig) {
|
||||
return HttpRouter.toWebHandler(
|
||||
HttpApiBuilder.layer(MockApi).pipe(
|
||||
Layer.provide(mockHandlers(config, { cursors: new Map<string, string>(), nextCursor: 0 })),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
)
|
||||
}
|
||||
|
||||
const corsHeaders = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "*",
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
|
||||
"bench:tabs": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
|
||||
"bench:entry": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-entry-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
|
||||
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
@@ -12,7 +12,12 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ComposerModel } from "./model"
|
||||
|
||||
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
|
||||
export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
@@ -21,6 +26,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
|
||||
<div class="flex flex-col gap-3">
|
||||
<ComposerEditor
|
||||
controller={props.model}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
|
||||
@@ -37,6 +37,7 @@ export type ComposerMode = "normal" | "shell"
|
||||
|
||||
export type ComposerEditorProps = {
|
||||
controller: ComposerEditorModel
|
||||
accentSubmit?: boolean
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
borderUnderlay?: boolean
|
||||
@@ -150,6 +151,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
props.controller.setEditor(element)
|
||||
renderComposerEditor(element, props.controller.parts())
|
||||
}}
|
||||
data-component="composer-editor"
|
||||
role="textbox"
|
||||
@@ -263,6 +265,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
accent={props.accentSubmit}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
@@ -748,6 +751,7 @@ export function ComposerEditorSubmitButton(props: {
|
||||
mode: ComposerMode
|
||||
stopping: boolean
|
||||
disabled: boolean
|
||||
accent?: boolean
|
||||
sendLabel: string
|
||||
stopLabel: string
|
||||
onSubmit: () => void
|
||||
@@ -766,10 +770,16 @@ export function ComposerEditorSubmitButton(props: {
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
|
||||
variant="contrast"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
class="size-7 rounded-md p-[6px] shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
classList={{
|
||||
"text-v2-text-text-contrast": !!props.accent && !props.stopping && !props.disabled,
|
||||
"text-v2-icon-icon-muted": !props.accent || props.stopping || props.disabled,
|
||||
}}
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
props.accent && !props.stopping && !props.disabled
|
||||
? "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-accent) 0%,var(--v2-background-bg-accent) 100%)"
|
||||
: "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -278,15 +278,16 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const directory = project?.worktree ?? session.location.directory
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
if (!options?.background) void ctx.data.session.message.sync(session.id).catch(() => undefined)
|
||||
// Commit cache/project changes with navigation instead of rebuilding
|
||||
// the outgoing Home list before leaving it.
|
||||
ctx.data.session.remember(session)
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||
return
|
||||
}
|
||||
ctx.projects.touch(directory)
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||
if (!options?.background) tabs.select(tab)
|
||||
ctx.data.session.remember(session)
|
||||
ctx.projects.open(directory)
|
||||
if (!options?.background) ctx.projects.touch(directory)
|
||||
tabs.select(tab)
|
||||
})
|
||||
},
|
||||
archive: async (session: SessionInfo) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function NewSessionView(props: {
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} />
|
||||
<Composer model={props.composer} accentSubmit={props.workspace.selection.workspace()} />
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
|
||||
@@ -221,7 +221,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
}
|
||||
|
||||
const current = createMemo(() => {
|
||||
const current = () => {
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => agent.current()?.model,
|
||||
@@ -229,7 +229,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
)
|
||||
if (!item) return
|
||||
return models.find(item)
|
||||
})
|
||||
}
|
||||
|
||||
const configured = () => {
|
||||
const item = agent.current()
|
||||
|
||||
@@ -216,6 +216,7 @@ export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegi
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
model: ActiveSessionRegionModel
|
||||
session: SessionModel
|
||||
accentSubmit: boolean
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
@@ -250,7 +251,7 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay />
|
||||
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { SessionInfo, SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
type Task =
|
||||
| { id: string; type: "subagent"; label: string; agent?: string }
|
||||
| { id: string; type: "shell"; label: string }
|
||||
|
||||
export function createSessionBackground(input: {
|
||||
sessionID: () => string | undefined
|
||||
messages: (id: string) => SessionMessageInfo[]
|
||||
sessions: () => SessionInfo[]
|
||||
status: (id: string) => "idle" | "running"
|
||||
shells: () => ShellInfo[]
|
||||
}) {
|
||||
const history = createMemo(() => {
|
||||
const completed = new Set<string>()
|
||||
const subagents: { id: string; type: "subagent"; label: string; agent: string | undefined }[] = []
|
||||
const shells: { partID: string; task: { id: string; type: "shell"; label: string } }[] = []
|
||||
const id = input.sessionID()
|
||||
const assistant = (id ? input.messages(id) : []).reduce<SessionMessageAssistant | undefined>((latest, message) => {
|
||||
if (message.type === "synthetic") {
|
||||
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
|
||||
completed.add(message.metadata.childID)
|
||||
if (message.metadata?.source === "shell") {
|
||||
if (typeof message.metadata.shellID === "string") completed.add(message.metadata.shellID)
|
||||
if (typeof message.metadata.jobID === "string") completed.add(message.metadata.jobID)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
if (message.type !== "assistant") return latest
|
||||
message.content.forEach((part) => {
|
||||
if (part.type !== "tool" || (part.name !== "subagent" && part.name !== "shell")) return
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return
|
||||
if (part.name === "subagent") {
|
||||
const sessionID = part.state.metadata.sessionID
|
||||
if (typeof sessionID !== "string") return
|
||||
const description = part.state.input.description
|
||||
const agent = part.state.input.agent
|
||||
subagents.push({
|
||||
id: sessionID,
|
||||
type: "subagent",
|
||||
label: typeof description === "string" ? description : sessionID,
|
||||
agent: typeof agent === "string" ? agent : undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
const shellID = part.state.metadata.shellID
|
||||
const command = part.state.input.command
|
||||
shells.push({
|
||||
partID: part.id,
|
||||
task: {
|
||||
id: typeof shellID === "string" ? shellID : part.id,
|
||||
type: "shell",
|
||||
label: typeof command === "string" ? command : part.id,
|
||||
},
|
||||
})
|
||||
})
|
||||
return message.time.completed === undefined ? message : latest
|
||||
}, undefined)
|
||||
|
||||
return {
|
||||
// Completion notices can identify the shell or its original tool call.
|
||||
subagents: subagents.filter((task) => !completed.has(task.id)),
|
||||
shells: shells
|
||||
.filter((item) => !completed.has(item.partID) && !completed.has(item.task.id))
|
||||
.map((item) => item.task),
|
||||
blocking:
|
||||
assistant?.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return []
|
||||
if (part.name !== "shell" && part.name !== "subagent") return []
|
||||
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
|
||||
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
|
||||
return [
|
||||
{
|
||||
type: part.name as "shell" | "subagent",
|
||||
partID: part.id,
|
||||
id: typeof value === "string" ? value : undefined,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
},
|
||||
]
|
||||
}) ?? [],
|
||||
}
|
||||
})
|
||||
const blocking = createMemo(() => history().blocking)
|
||||
const tasks = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return []
|
||||
const current = history()
|
||||
const active = input.sessions().flatMap((info) => {
|
||||
if (info?.parentID !== id) return []
|
||||
if (input.status(info.id) === "idle") return []
|
||||
if (
|
||||
current.blocking.some(
|
||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
|
||||
})
|
||||
const running = input.shells().flatMap((shell) => {
|
||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||
if (
|
||||
current.blocking.some(
|
||||
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
|
||||
})
|
||||
return [
|
||||
...new Map<string, Task>(
|
||||
[...current.subagents, ...active, ...current.shells, ...running].map((task) => [task.id, task]),
|
||||
).values(),
|
||||
]
|
||||
})
|
||||
return { blocking, tasks }
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
|
||||
import { createSessionBackground } from "@/session/requests/background"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
|
||||
export function createSessionRequestModel() {
|
||||
@@ -47,12 +46,105 @@ export function createSessionRequestModel() {
|
||||
const id = params.id
|
||||
return !!id && !data.session.get(id)?.parentID
|
||||
}
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => (primary() ? params.id : undefined),
|
||||
messages: data.session.message.list,
|
||||
sessions: data.session.list,
|
||||
status: data.session.status,
|
||||
shells: () => data.shell.list({ directory: sdk().directory }),
|
||||
const backgroundBlocking = createMemo(() => {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const assistant = data.session.message
|
||||
.list(id)
|
||||
.findLast((message) => message.type === "assistant" && message.time.completed === undefined)
|
||||
if (assistant?.type !== "assistant") return []
|
||||
return assistant.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return []
|
||||
if (part.name !== "shell" && part.name !== "subagent") return []
|
||||
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
|
||||
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
|
||||
return [
|
||||
{
|
||||
type: part.name as "shell" | "subagent",
|
||||
partID: part.id,
|
||||
id: typeof value === "string" ? value : undefined,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const backgroundTasks = createMemo(() => {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const blocking = backgroundBlocking()
|
||||
const messages = data.session.message.list(id)
|
||||
const completed = new Set(
|
||||
messages.flatMap((message) => {
|
||||
if (message.type !== "synthetic") return []
|
||||
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
|
||||
return [message.metadata.childID]
|
||||
if (message.metadata?.source === "shell")
|
||||
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
|
||||
return []
|
||||
}),
|
||||
)
|
||||
const backgrounded = messages.flatMap((message) => {
|
||||
if (message.type !== "assistant") return []
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.name !== "subagent") return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const sessionID = part.state.metadata.sessionID
|
||||
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
|
||||
const description = part.state.input.description
|
||||
const agent = part.state.input.agent
|
||||
return [
|
||||
{
|
||||
id: sessionID,
|
||||
type: "subagent" as const,
|
||||
label: typeof description === "string" ? description : sessionID,
|
||||
agent: typeof agent === "string" ? agent : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const active = data.session.list().flatMap((info) => {
|
||||
if (info?.parentID !== id) return []
|
||||
if (data.session.status(info.id) === "idle") return []
|
||||
if (
|
||||
blocking.some(
|
||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
|
||||
})
|
||||
const backgroundShells = messages.flatMap((message) => {
|
||||
if (message.type !== "assistant") return []
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const shellID = part.state.metadata.shellID
|
||||
if (typeof shellID === "string" && completed.has(shellID)) return []
|
||||
const command = part.state.input.command
|
||||
return [
|
||||
{
|
||||
id: typeof shellID === "string" ? shellID : part.id,
|
||||
type: "shell" as const,
|
||||
label: typeof command === "string" ? command : part.id,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
|
||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||
if (
|
||||
blocking.some(
|
||||
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
|
||||
})
|
||||
return [
|
||||
...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(),
|
||||
]
|
||||
})
|
||||
const moveToBackground = async () => {
|
||||
if (!primary()) return
|
||||
@@ -99,8 +191,8 @@ export function createSessionRequestModel() {
|
||||
permissionRequest,
|
||||
permissionResponding,
|
||||
background: {
|
||||
blocking: background.blocking,
|
||||
tasks: background.tasks,
|
||||
blocking: backgroundBlocking,
|
||||
tasks: backgroundTasks,
|
||||
move: moveToBackground,
|
||||
},
|
||||
decide,
|
||||
|
||||
@@ -145,7 +145,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
<Show when={!review.mobile.changes() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
|
||||
<ActiveSessionComposerRegion
|
||||
model={composer}
|
||||
session={session}
|
||||
accentSubmit={session.workspace.current()}
|
||||
onResponseSubmit={timeline.actions.resume}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!!session.identity.params.id && mobileTabsBottom()}>
|
||||
|
||||
@@ -5,27 +5,18 @@ import { createSessionResolution } from "./session-resolution"
|
||||
describe("session resolution", () => {
|
||||
test("waits for a route session ID", () => {
|
||||
createRoot((dispose) => {
|
||||
const syncs = { session: 0, message: 0 }
|
||||
let syncs = 0
|
||||
const sessions = {
|
||||
get: () => undefined,
|
||||
sync: () => {
|
||||
syncs.session++
|
||||
syncs++
|
||||
return Promise.resolve()
|
||||
},
|
||||
message: {
|
||||
sync: () => {
|
||||
syncs.message++
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
}
|
||||
const session = createSessionResolution(
|
||||
() => undefined,
|
||||
() => sessions,
|
||||
)
|
||||
const session = createSessionResolution(() => undefined, () => sessions)
|
||||
|
||||
expect(session()).toBeUndefined()
|
||||
expect(syncs).toEqual({ session: 0, message: 0 })
|
||||
expect(syncs).toBe(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { createMemo, createRenderEffect, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { sessionNotFoundError } from "@/runtime/server/errors"
|
||||
|
||||
type SessionStore<T> = {
|
||||
get: (id: string) => T | undefined
|
||||
sync: (id: string, options?: { children?: boolean }) => Promise<unknown>
|
||||
message: {
|
||||
sync: (id: string) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type Resolution<T> = { id: string; store: SessionStore<T> } & (
|
||||
@@ -43,17 +40,13 @@ export function createSessionResolution<T>(
|
||||
})
|
||||
const [status, setStatus] = createSignal<Resolution<T>>()
|
||||
|
||||
// Start independent reads before constructing the selected view, including
|
||||
// when its metadata is cached but its transcript has never been loaded.
|
||||
createRenderEffect(
|
||||
createEffect(
|
||||
on([sessionID, sessions] as const, ([id, store]) => {
|
||||
if (!id) return
|
||||
let stale = false
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
// The timeline owns message errors; metadata resolution stays independent.
|
||||
void store.message.sync(id).catch(() => undefined)
|
||||
if (cached() && !options?.children) {
|
||||
setStatus({ id, store, state: "settled" })
|
||||
return
|
||||
|
||||
@@ -79,17 +79,6 @@ describe("visibleTimelineMessages", () => {
|
||||
?.map((message) => message.id),
|
||||
).toEqual(["msg_2", "msg_5"])
|
||||
expect(projection.assistantMessagesByParent().has(steer.id)).toBe(false)
|
||||
expect([...projection.messageRowIndex()]).toEqual([
|
||||
["msg_1", 0],
|
||||
["msg_3", 2],
|
||||
])
|
||||
expect([...projection.messageLastRowIndex()]).toEqual([
|
||||
["msg_1", 1],
|
||||
["msg_3", 3],
|
||||
])
|
||||
expect([...projection.lastAssistantGroupKey()]).toEqual([["msg_1", "context:msg_5:tool_read"]])
|
||||
expect(projection.rowByKey().get("user-message:msg_1")).toBe(projection.rows()[0])
|
||||
expect(projection.rowByKey().size).toBe(projection.rows().length)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
|
||||
import { getReadyMarkdown, preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
@@ -317,16 +316,6 @@ type MessageTimelineProps = {
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
const controller = createTimelineController({ session: props.session })
|
||||
const tail = props.pinned ? controller.data.projection.rows().at(-1) : undefined
|
||||
if (tail?._tag === "AssistantPart" && tail.group.type === "part") {
|
||||
const message = controller.data.projection.messageByID().get(tail.group.ref.messageID)
|
||||
if (message?.type === "assistant" && message.time.completed !== undefined) {
|
||||
const content = Timeline.resolveContent(message, tail.group.ref.partID)
|
||||
// Start the required worker job while the rest of the selected view is constructed.
|
||||
if (content?.type === "text" && content.text.trim())
|
||||
void preloadMarkdown(content.text, tail.group.ref.partID).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
|
||||
)
|
||||
@@ -411,38 +400,6 @@ function MessageTimelineView(
|
||||
onSelectionInteraction: props.onSelectionInteraction,
|
||||
onUserScroll: props.onUserScroll,
|
||||
onHistoryScroll: props.onHistoryScroll,
|
||||
canRenderImmediately: (row, disclosure) => {
|
||||
if (row._tag === "TurnGap" || row._tag === "TurnDivider") return true
|
||||
if (row._tag === "Notice") {
|
||||
const message = messageByID().get(row.messageID)
|
||||
return (
|
||||
(message?.type === "system" || message?.type === "synthetic") &&
|
||||
(message.description ?? message.text).length <= 1024
|
||||
)
|
||||
}
|
||||
if (row._tag === "UserMessage") {
|
||||
const message = messageByID().get(row.userMessageID)
|
||||
if (message?.type !== "user" || message.text.length > 1024 || message.files?.length || message.agents?.length)
|
||||
return false
|
||||
const presentation = readPromptPresentation(message.metadata)
|
||||
return (
|
||||
(presentation?.displayText ?? message.text).length <= 1024 &&
|
||||
!presentation?.comments?.length &&
|
||||
!parseCommentNote(message.text)
|
||||
)
|
||||
}
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
|
||||
const message = messageByID().get(row.group.ref.messageID)
|
||||
if (message?.type !== "assistant" || message.time.completed === undefined) return false
|
||||
const content = Timeline.resolveContent(message, row.group.ref.partID)
|
||||
if (content?.type === "reasoning")
|
||||
return !(disclosure[row.group.ref.partID] ?? props.data.reasoningMode() === "full")
|
||||
return (
|
||||
content?.type === "text" &&
|
||||
content.text.length <= 1024 &&
|
||||
!!getReadyMarkdown({ raw: content.text, src: content.text }, `${row.group.ref.partID}:0:full`)
|
||||
)
|
||||
},
|
||||
setRevealMessage: props.setRevealMessage,
|
||||
setScrollToEnd: props.setScrollToEnd,
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
|
||||
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
|
||||
const data = useData()
|
||||
const prepared = new Set<string>()
|
||||
|
||||
const [resource] = createResource(
|
||||
() => input.session.identity.sessionID(),
|
||||
@@ -29,12 +30,14 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
|
||||
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
|
||||
maxPages: leadingTurnPageLimit,
|
||||
}).catch(() => undefined)
|
||||
if (input.session.identity.sessionKey() === key) prepared.add(key)
|
||||
},
|
||||
)
|
||||
const ready = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
// Enrich the partial leading group without withholding the already loaded tail.
|
||||
return !id || data.session.message.list(id).length > 0 || !resource.loading
|
||||
if (!id || prepared.has(input.session.identity.sessionKey()) || !resource.loading) return true
|
||||
const messages = data.session.message.list(id)
|
||||
return messages.length > 0 && !leadingTurnNeedsParent(messages)
|
||||
})
|
||||
const more = () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
@@ -96,29 +96,38 @@ export function createTimelineProjection(input: {
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
)
|
||||
const indexes = createMemo(() => {
|
||||
const rowByKey = new Map<string, TimelineRow.TimelineRow>()
|
||||
const messageRowIndex = new Map<string, number>()
|
||||
const messageLastRowIndex = new Map<string, number>()
|
||||
const lastAssistantGroupKey = new Map<string, string>()
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||
const messageRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
rows().forEach((row, index) => {
|
||||
rowByKey.set(TimelineRow.key(row), row)
|
||||
if (!("userMessageID" in row)) return
|
||||
if (!messageRowIndex.has(row.userMessageID)) messageRowIndex.set(row.userMessageID, index)
|
||||
messageLastRowIndex.set(row.userMessageID, index)
|
||||
if (row._tag === "AssistantPart") lastAssistantGroupKey.set(row.userMessageID, row.group.key)
|
||||
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
|
||||
result.set(row.userMessageID, index)
|
||||
})
|
||||
return { rowByKey, messageRowIndex, messageLastRowIndex, lastAssistantGroupKey }
|
||||
return result
|
||||
})
|
||||
const messageLastRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
rows().forEach((row, index) => {
|
||||
if ("userMessageID" in row) result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const lastAssistantGroupKey = createMemo(() => {
|
||||
const result = new Map<string, string>()
|
||||
rows().forEach((row) => {
|
||||
if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
activeMessageID,
|
||||
assistantMessagesByParent,
|
||||
lastAssistantGroupKey: () => indexes().lastAssistantGroupKey,
|
||||
lastAssistantGroupKey,
|
||||
messageByID: sessionMessageByID,
|
||||
messageRowIndex: () => indexes().messageRowIndex,
|
||||
messageLastRowIndex: () => indexes().messageLastRowIndex,
|
||||
rowByKey: () => indexes().rowByKey,
|
||||
messageRowIndex,
|
||||
messageLastRowIndex,
|
||||
rowByKey,
|
||||
rows,
|
||||
sessionMessageByID,
|
||||
userContextByID,
|
||||
|
||||
@@ -55,10 +55,6 @@ type Input = {
|
||||
onSelectionInteraction: (event: MouseEvent) => void
|
||||
onUserScroll: (target?: EventTarget | null) => void
|
||||
onHistoryScroll: () => void
|
||||
canRenderImmediately?: (
|
||||
row: TimelineRow.TimelineRow,
|
||||
disclosure: Readonly<Record<string, boolean | undefined>>,
|
||||
) => boolean
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
}
|
||||
@@ -79,63 +75,17 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const coldBottomMount = !initialMeasurements?.length && input.pinned()
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
|
||||
const [overscan, setOverscan] = createSignal(2)
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
|
||||
equals: (previous, next) => previous.length === next.length && previous.every((key, index) => key === next[index]),
|
||||
})
|
||||
const knownKeys = new Set(rowKeys())
|
||||
const knownKeys = new Set(rows().map(TimelineRow.key))
|
||||
const addedKeys = new Set<string>()
|
||||
const getItemKey = createMemo(() => {
|
||||
const keys = rowKeys()
|
||||
keys
|
||||
.filter((key) => !knownKeys.has(key))
|
||||
.forEach((key) => {
|
||||
knownKeys.add(key)
|
||||
addedKeys.add(key)
|
||||
})
|
||||
return (index: number) => keys[index] ?? `removed:${index}`
|
||||
})
|
||||
const rangeExtractor = createMemo(() => {
|
||||
const id = input.projection.activeMessageID()
|
||||
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const initialTail = rendering.initialTail && input.pinned()
|
||||
return (range: Range) => {
|
||||
// Batch a bounded cheap suffix, but stop before unknown/large content.
|
||||
// A large tail still mounts alone before estimates expose earlier history.
|
||||
const start = Math.max(0, range.startIndex - 2)
|
||||
const boundary = initialTail
|
||||
? rows()
|
||||
.slice(start, range.count)
|
||||
.findLastIndex(
|
||||
(row) =>
|
||||
!(
|
||||
row._tag === "AssistantPart" &&
|
||||
row.group.type === "context" &&
|
||||
row.group.refs.length <= 16 &&
|
||||
!toolOpen[`context:${row.group.key}`]
|
||||
) && !input.canRenderImmediately?.(row, toolOpen),
|
||||
)
|
||||
: -1
|
||||
const first = Math.min(range.count - 1, start + boundary + 1)
|
||||
const indexes = initialTail
|
||||
? Array.from({ length: range.count - first }, (_, index) => first + index)
|
||||
: defaultRangeExtractor({ ...range, overscan: 2 })
|
||||
return filterVirtualIndexes(
|
||||
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
range.count,
|
||||
)
|
||||
}
|
||||
})
|
||||
const measuredElements = new WeakSet<Element>()
|
||||
let touchStart: number | undefined
|
||||
let pointerHeld = false
|
||||
let maxScroll = 0
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
let scrollTop = 0
|
||||
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
|
||||
let batchingColdSizes = false
|
||||
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
@@ -143,16 +93,10 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
// Route navigation detaches and reattaches the scroll element, which drops its offset.
|
||||
observeElementOffset: (instance, callback) => {
|
||||
reportOffset = (offset, scrolling) => {
|
||||
callback(offset, scrolling)
|
||||
settleColdBottom()
|
||||
}
|
||||
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
|
||||
observeElementOffset: (instance, callback) =>
|
||||
observeElementOffsetReconnectAware(instance, callback, () => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
settleColdBottom()
|
||||
})
|
||||
},
|
||||
}),
|
||||
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => fallbackItemSize,
|
||||
@@ -166,17 +110,28 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (box) return Math.round(box.blockSize)
|
||||
if (initial) {
|
||||
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
|
||||
if (size !== undefined || coldPending) return size ?? fallbackItemSize
|
||||
if (size !== undefined) return size
|
||||
}
|
||||
return element.offsetHeight
|
||||
},
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (batchingColdSizes && input.pinned()) return
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
},
|
||||
get getItemKey() {
|
||||
return getItemKey()
|
||||
const items = rows()
|
||||
items
|
||||
.map(TimelineRow.key)
|
||||
.filter((key) => !knownKeys.has(key))
|
||||
.forEach((key) => {
|
||||
knownKeys.add(key)
|
||||
addedKeys.add(key)
|
||||
})
|
||||
return (index: number) => {
|
||||
const row = items[index]
|
||||
if (!row) return `removed:${index}`
|
||||
return TimelineRow.key(row)
|
||||
}
|
||||
},
|
||||
get anchorTo() {
|
||||
return input.pinned() ? "end" : "start"
|
||||
@@ -190,7 +145,16 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
paddingEnd: 64,
|
||||
get rangeExtractor() {
|
||||
return rangeExtractor()
|
||||
const id = input.projection.activeMessageID()
|
||||
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const buffer = overscan()
|
||||
return (range: Range) => {
|
||||
const indexes = defaultRangeExtractor({ ...range, overscan: buffer })
|
||||
return filterVirtualIndexes(
|
||||
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
range.count,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
@@ -202,7 +166,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const row = rows()[index]
|
||||
if (!row) return
|
||||
const key = TimelineRow.key(row)
|
||||
if ((virtualizer.itemSizeCache.get(key) ?? fallbackItemSize) === size) {
|
||||
if (virtualizer.itemSizeCache.get(key) === size) {
|
||||
pendingSizes.delete(index)
|
||||
return
|
||||
}
|
||||
@@ -214,19 +178,12 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (!pendingSizes.size) return
|
||||
const sizes = [...pendingSizes]
|
||||
pendingSizes.clear()
|
||||
// The hidden pinned mount needs one bottom write after the whole batch,
|
||||
// not a layout-forcing scroll adjustment for every measured row.
|
||||
batchingColdSizes = coldPending && input.pinned()
|
||||
batch(() => {
|
||||
sizes.forEach(([index, value]) => {
|
||||
const row = rows()[index]
|
||||
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
|
||||
})
|
||||
})
|
||||
batchingColdSizes = false
|
||||
if (coldPending) pinColdBottom()
|
||||
settleColdBottom()
|
||||
if (coldPending) return
|
||||
if (!input.pinned()) return
|
||||
const root = listRoot()
|
||||
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
|
||||
@@ -260,73 +217,41 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
})
|
||||
})
|
||||
|
||||
let coldPending = coldBottomMount
|
||||
let settleQueued = false
|
||||
let contentObserver: MutationObserver | undefined
|
||||
let viewportObserver: ResizeObserver | undefined
|
||||
const pinColdBottom = () => {
|
||||
const root = listRoot()
|
||||
if (!input.pinned() || !virtualContent || !root) return
|
||||
// scrollToEnd computes its target from the DOM, not the new size cache.
|
||||
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
|
||||
// Report after core size adjustments finish so they cannot apply a delta
|
||||
// twice. This avoids waiting a frame for the native scroll event.
|
||||
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
|
||||
}
|
||||
const pendingMeasurements = () => {
|
||||
const items = virtualizer.getVirtualItems()
|
||||
return (
|
||||
(rows().length > 0 && items.length === 0) ||
|
||||
items.some((item) => !virtualizer.elementsCache.get(item.key)?.isConnected)
|
||||
)
|
||||
let settleFrame: number | undefined
|
||||
let overscanFrame: number | undefined
|
||||
let overscanTimer: number | undefined
|
||||
const expandOverscan = () => {
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
overscanFrame = undefined
|
||||
// Let the visible rows paint before building the normal interaction buffer.
|
||||
overscanTimer = window.setTimeout(() => {
|
||||
overscanTimer = undefined
|
||||
setOverscan(20)
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
const pendingMeasurements = () =>
|
||||
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
|
||||
const settleColdBottom = () => {
|
||||
if (!coldPending || settleQueued) return
|
||||
settleQueued = true
|
||||
queueMicrotask(() => {
|
||||
settleQueued = false
|
||||
const root = listRoot()
|
||||
if (!coldPending || !virtualContent?.isConnected || !root) return
|
||||
if (virtualContent.querySelector(pendingMarkdown)) return
|
||||
if (!root.clientHeight) return
|
||||
// Markdown can finish before ResizeObserver delivers its new box. The
|
||||
// normal measureElement path skips reads while scrolling; this gate needs
|
||||
// current boxes before expanding the estimated range or revealing it.
|
||||
virtualizer.elementsCache.forEach((element) => {
|
||||
if (element.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(element), element.offsetHeight)
|
||||
})
|
||||
if (pendingSizes.size || pendingMeasurements()) return
|
||||
pinColdBottom()
|
||||
if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
|
||||
// The scroll event must update the range before newly exposed rows can reveal.
|
||||
if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
|
||||
return
|
||||
if (rendering.initialTail) {
|
||||
setRendering("initialTail", false)
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
|
||||
settleFrame = requestAnimationFrame(settleColdBottom)
|
||||
return
|
||||
}
|
||||
settleFrame = requestAnimationFrame(() => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
|
||||
settleColdBottom()
|
||||
return
|
||||
}
|
||||
if (pendingSizes.size || pendingMeasurements() || virtualContent.querySelector(pendingMarkdown)) return
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
viewportObserver?.disconnect()
|
||||
virtualContent.style.removeProperty("visibility")
|
||||
settleFrame = undefined
|
||||
virtualContent?.style.removeProperty("visibility")
|
||||
expandOverscan()
|
||||
})
|
||||
}
|
||||
onMount(() => {
|
||||
if (!coldPending || !virtualContent) return
|
||||
contentObserver = new MutationObserver(settleColdBottom)
|
||||
contentObserver.observe(virtualContent, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["data-markdown-ready"],
|
||||
})
|
||||
viewportObserver = new ResizeObserver(settleColdBottom)
|
||||
const root = listRoot()
|
||||
if (root) viewportObserver.observe(root)
|
||||
settleColdBottom()
|
||||
if (coldBottomMount) settleFrame = requestAnimationFrame(settleColdBottom)
|
||||
if (!coldBottomMount) expandOverscan()
|
||||
})
|
||||
|
||||
let measuredSessionKey = input.sessionKey()
|
||||
@@ -340,14 +265,12 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
|
||||
const bindListRoot = (root: HTMLDivElement) => {
|
||||
if (root === listRoot()) return
|
||||
setListRoot(root)
|
||||
// TanStack owns anchoring; browser scroll anchoring would fight its adjustments.
|
||||
root.style.overflowAnchor = "none"
|
||||
setListRoot(root)
|
||||
scrollTop = root.scrollTop
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
input.setScrollRef(root)
|
||||
viewportObserver?.observe(root)
|
||||
settleColdBottom()
|
||||
}
|
||||
|
||||
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
|
||||
@@ -355,11 +278,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
input.onUserScroll(event.target)
|
||||
if (event.deltaY < 0) input.onUnpin()
|
||||
setOverscan(20)
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
input.onUserScroll(event.target)
|
||||
touchStart = event.touches[0]?.clientY
|
||||
setOverscan(20)
|
||||
}
|
||||
|
||||
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
|
||||
@@ -376,6 +301,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
input.onUserScroll(event.target)
|
||||
pointerHeld = true
|
||||
setOverscan(20)
|
||||
}
|
||||
const releasePointer = () => {
|
||||
pointerHeld = false
|
||||
@@ -396,6 +322,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
input.onUserScroll(event.currentTarget)
|
||||
if (upwardKeys.has(key)) input.onUnpin()
|
||||
setOverscan(20)
|
||||
}
|
||||
|
||||
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
|
||||
@@ -411,7 +338,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
|
||||
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
|
||||
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
settleColdBottom()
|
||||
input.onScheduleScrollState(root)
|
||||
input.onHistoryScroll()
|
||||
}
|
||||
@@ -550,9 +476,9 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
cache.delete(ownerSessionKey)
|
||||
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
while (cache.size > 16) cache.delete(cache.keys().next().value!)
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
viewportObserver?.disconnect()
|
||||
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame)
|
||||
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
|
||||
if (overscanTimer !== undefined) window.clearTimeout(overscanTimer)
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setScrollToEnd?.(() => {})
|
||||
|
||||
@@ -11,8 +11,9 @@ import Shell from "@/shell/shell"
|
||||
import { requireServerKey } from "./session"
|
||||
|
||||
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadDraftRoute = () => Promise.all([import("@/new-session/route"), File.preload()]).then(([module]) => module)
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
|
||||
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ export function TabNavItem(props: {
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
@@ -97,13 +98,11 @@ export function TabNavItem(props: {
|
||||
|
||||
createEffect(() => {
|
||||
title()
|
||||
props.active
|
||||
props.orientation
|
||||
props.forceTruncate
|
||||
editing()
|
||||
scheduleTitleOverflow()
|
||||
})
|
||||
|
||||
// The overflow fade changes title padding; observe the stable tab box, not that feedback.
|
||||
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
|
||||
onCleanup(() => {
|
||||
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createEffect, createMemo, createResource, For, onCleanup, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
@@ -25,6 +26,7 @@ function SessionTabSlot(props: {
|
||||
id: string
|
||||
index: number
|
||||
active: boolean
|
||||
forceTruncate: boolean
|
||||
orientation: "horizontal" | "vertical"
|
||||
session: SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
@@ -67,6 +69,7 @@ function SessionTabSlot(props: {
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
orientation={props.orientation}
|
||||
/>
|
||||
@@ -79,6 +82,7 @@ function SessionTabEntry(props: {
|
||||
id: string
|
||||
index: number
|
||||
active: boolean
|
||||
forceTruncate: boolean
|
||||
orientation: "horizontal" | "vertical"
|
||||
serverCtx: ServerCtx | undefined
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
@@ -165,6 +169,7 @@ function SessionTabEntry(props: {
|
||||
id={props.id}
|
||||
index={props.index}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
orientation={props.orientation}
|
||||
session={session()}
|
||||
fallbackTitle={
|
||||
@@ -233,15 +238,19 @@ export function TitlebarTabStrip(props: {
|
||||
orientation?: "horizontal" | "vertical"
|
||||
tabs: Tab[]
|
||||
currentTab: Tab | undefined
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
onReorder: (keys: string[]) => void
|
||||
onOverflowChange: (overflowing: boolean) => void
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const vertical = () => props.orientation === "vertical"
|
||||
let scrollRef!: HTMLDivElement
|
||||
let listRef!: HTMLDivElement
|
||||
let resizeFrame: number | undefined
|
||||
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
|
||||
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
|
||||
const visibleTabIds = () => visibleTabs().map(tabKey)
|
||||
@@ -272,6 +281,38 @@ export function TitlebarTabStrip(props: {
|
||||
if (next) props.onNavigate(next)
|
||||
}
|
||||
|
||||
function refreshOverflow() {
|
||||
if (!scrollRef) return
|
||||
props.onOverflowChange(
|
||||
vertical() ? scrollRef.scrollHeight > scrollRef.clientHeight : scrollRef.scrollWidth > scrollRef.clientWidth,
|
||||
)
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => [scrollRef, listRef],
|
||||
() => {
|
||||
if (resizeFrame !== undefined) return
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
refreshOverflow()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
refreshOverflow()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.tabs.length
|
||||
visibleTabIds()
|
||||
refreshOverflow()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot={vertical() ? "vertical-tabs" : "titlebar-tabs"}
|
||||
@@ -286,6 +327,7 @@ export function TitlebarTabStrip(props: {
|
||||
"flex-row items-center gap-1.5 overflow-x-auto": !vertical(),
|
||||
"max-h-full flex-col overflow-y-auto overflow-x-hidden": vertical(),
|
||||
}}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
@@ -356,6 +398,7 @@ export function TitlebarTabStrip(props: {
|
||||
id={id}
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
forceTruncate={props.forceTruncate}
|
||||
orientation={vertical() ? "vertical" : "horizontal"}
|
||||
serverCtx={serverCtx()}
|
||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
@@ -336,6 +336,8 @@ export function Titlebar(props: {
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
|
||||
@@ -380,6 +382,8 @@ export function Titlebar(props: {
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant" })
|
||||
@@ -420,6 +424,8 @@ export function Titlebar(props: {
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
forceTruncate={false}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo, SessionMessageAssistantTool, ShellInfo } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSessionBackground } from "@/session/requests/background"
|
||||
|
||||
const tool = (
|
||||
id: string,
|
||||
name: string,
|
||||
metadata: Record<string, string>,
|
||||
input: Record<string, string> = {},
|
||||
status: "completed" | "running" = "completed",
|
||||
): SessionMessageAssistantTool => ({
|
||||
id,
|
||||
name,
|
||||
type: "tool",
|
||||
state:
|
||||
status === "running"
|
||||
? { status, input, metadata }
|
||||
: { status, input, metadata, content: [{ type: "text", text: "backgrounded" }] },
|
||||
time: { created: 0 },
|
||||
})
|
||||
|
||||
const assistant = (id: string, content: SessionMessageAssistantTool[], completed?: number) => ({
|
||||
id,
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content,
|
||||
time: { created: 0, completed },
|
||||
})
|
||||
|
||||
const session = (id: string): SessionInfo => ({
|
||||
id,
|
||||
title: id,
|
||||
parentID: "root",
|
||||
projectID: "project",
|
||||
location: { directory: "/project" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
})
|
||||
|
||||
const shell = (id: string, command: string): ShellInfo => ({
|
||||
id,
|
||||
command,
|
||||
status: "running",
|
||||
cwd: "/project",
|
||||
shell: "sh",
|
||||
file: "output",
|
||||
metadata: { sessionID: "root" },
|
||||
time: { started: 0 },
|
||||
})
|
||||
|
||||
const notification = (id: string, metadata: Record<string, string>) => ({
|
||||
id,
|
||||
type: "synthetic" as const,
|
||||
text: "complete",
|
||||
metadata,
|
||||
time: { created: 0 },
|
||||
})
|
||||
|
||||
describe("createSessionBackground", () => {
|
||||
test("excludes completed children and shells using either shell or tool-call IDs", () => {
|
||||
createRoot((dispose) => {
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => "root",
|
||||
messages: () => [
|
||||
notification("before", { source: "subagent", childID: "before-child" }),
|
||||
assistant("assistant", [
|
||||
tool("before-part", "subagent", { status: "running", sessionID: "before-child" }),
|
||||
tool("shell-part", "shell", { status: "running", shellID: "process" }),
|
||||
tool("shell-call", "shell", { status: "running", shellID: "shell-id" }),
|
||||
tool("legacy-call", "shell", { status: "running", shellID: "legacy-shell" }),
|
||||
tool("child-part", "subagent", { status: "running", sessionID: "child" }, { agent: "explore" }),
|
||||
]),
|
||||
notification("shell-done", { source: "shell", jobID: "shell-part" }),
|
||||
notification("shell-id-done", { source: "shell", shellID: "shell-id" }),
|
||||
notification("legacy-done", { source: "shell", jobID: "legacy-shell" }),
|
||||
],
|
||||
sessions: () => [],
|
||||
status: () => "idle",
|
||||
shells: () => [],
|
||||
})
|
||||
expect(background.tasks()).toEqual([{ id: "child", type: "subagent", label: "child", agent: "explore" }])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("joins live tasks while idle without rescanning history, then switches sessions", () => {
|
||||
createRoot((dispose) => {
|
||||
const [store, setStore] = createStore({
|
||||
id: "root" as string | undefined,
|
||||
messages: [
|
||||
assistant("assistant", [
|
||||
tool("shell-part", "shell", { status: "running", shellID: "shell" }, { command: "old command" }),
|
||||
tool("child-part", "subagent", { status: "running", sessionID: "child" }),
|
||||
]),
|
||||
],
|
||||
sessions: [session("live-child"), session("child")],
|
||||
status: { root: "idle", child: "idle", "live-child": "idle" } as Record<string, "idle" | "running">,
|
||||
shells: [{ ...shell("shell", "command"), status: "exited" as ShellInfo["status"] }],
|
||||
})
|
||||
let scans = 0
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => store.id,
|
||||
messages: (id) => {
|
||||
scans += 1
|
||||
return id === "root" ? store.messages : []
|
||||
},
|
||||
sessions: () => store.sessions,
|
||||
status: (id) => store.status[id],
|
||||
shells: () => store.shells,
|
||||
})
|
||||
const blocking = background.blocking()
|
||||
const initial = background.tasks()
|
||||
expect(initial.map((task) => task.id)).toEqual(["child", "shell"])
|
||||
|
||||
setStore("status", { child: "running", "live-child": "running" })
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["child", "live-child", "shell"])
|
||||
setStore("shells", 0, "status", "running")
|
||||
expect(background.tasks().at(-1)?.label).toBe("command")
|
||||
setStore("sessions", 1, "title", "renamed")
|
||||
expect(background.tasks()[0]?.label).toBe("renamed")
|
||||
setStore("shells", 0, "command", "updated command")
|
||||
const live = background.tasks()
|
||||
expect(live).toEqual([
|
||||
{ id: "child", type: "subagent", label: "renamed" },
|
||||
{ id: "live-child", type: "subagent", label: "live-child" },
|
||||
{ id: "shell", type: "shell", label: "updated command" },
|
||||
])
|
||||
expect(background.blocking()).toBe(blocking)
|
||||
expect(scans).toBe(1)
|
||||
|
||||
setStore("id", "other")
|
||||
expect(background.tasks()).toEqual([])
|
||||
setStore("id", "root")
|
||||
expect(background.tasks()).toEqual(live)
|
||||
setStore("status", { child: "idle", "live-child": "idle" })
|
||||
setStore("shells", 0, "status", "exited")
|
||||
expect(background.tasks()).toEqual(initial)
|
||||
expect(scans).toBe(3)
|
||||
setStore("id", undefined)
|
||||
expect(background.tasks()).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks blocking, backgrounding, and completion through nested store updates", () => {
|
||||
createRoot((dispose) => {
|
||||
const [store, setStore] = createStore({
|
||||
messages: [
|
||||
assistant("earlier", [tool("old-part", "subagent", { sessionID: "old-child" }, {}, "running")]),
|
||||
assistant("current", [
|
||||
tool("child-part", "subagent", { sessionID: "child" }, {}, "running"),
|
||||
tool("shell-part", "shell", {}, { command: "build" }, "running"),
|
||||
]),
|
||||
assistant("completed", [], 0),
|
||||
],
|
||||
notification: notification("notice", { source: "subagent", childID: "other-child" }),
|
||||
status: { child: "running", "old-child": "running" } as Record<string, "idle" | "running">,
|
||||
})
|
||||
const messages = store.messages
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => "root",
|
||||
messages: () => [...store.messages, store.notification],
|
||||
sessions: () => [session("child"), session("old-child")],
|
||||
status: (id) => store.status[id],
|
||||
shells: () => [shell("shell", "build")],
|
||||
})
|
||||
expect(background.blocking()).toEqual([
|
||||
{ type: "subagent", partID: "child-part", id: "child", label: undefined },
|
||||
{ type: "shell", partID: "shell-part", id: undefined, label: "build" },
|
||||
])
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["old-child"])
|
||||
|
||||
setStore("messages", 1, "content", 0, "state", {
|
||||
status: "completed",
|
||||
input: { description: "background child" },
|
||||
metadata: { status: "running", sessionID: "child" },
|
||||
content: [{ type: "text", text: "backgrounded" }],
|
||||
})
|
||||
expect(store.messages).toBe(messages)
|
||||
expect(background.blocking().map((task) => task.partID)).toEqual(["shell-part"])
|
||||
setStore("status", "child", "idle")
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["child", "old-child"])
|
||||
expect(background.tasks()[0]?.label).toBe("background child")
|
||||
setStore("notification", "metadata", "childID", "child")
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["old-child"])
|
||||
setStore("messages", [0, 1], "time", "completed", 1)
|
||||
expect(background.blocking()).toEqual([])
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["old-child", "shell"])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,8 @@ function createFixture(initial: Record<string, Session> = {}) {
|
||||
const [cache, setCache] = createSignal(initial)
|
||||
const deferred = new Map<string, PromiseWithResolvers<unknown>>()
|
||||
const resolves: string[] = []
|
||||
const messages = { syncs: [] as string[], ...Promise.withResolvers<unknown>() }
|
||||
return {
|
||||
resolves,
|
||||
messages,
|
||||
sessions: {
|
||||
get: (id: string) => cache()[id],
|
||||
sync: (id: string) => {
|
||||
@@ -27,12 +25,6 @@ function createFixture(initial: Record<string, Session> = {}) {
|
||||
deferred.set(id, entry)
|
||||
return entry.promise
|
||||
},
|
||||
message: {
|
||||
sync: (id: string) => {
|
||||
messages.syncs.push(id)
|
||||
return messages.promise
|
||||
},
|
||||
},
|
||||
},
|
||||
settle(id: string) {
|
||||
setCache({ ...cache(), [id]: sessionOf(id) })
|
||||
@@ -59,34 +51,7 @@ const flush = async () => {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
test("starts metadata and messages in parallel once the route has a session ID", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const fixture = createFixture()
|
||||
const [id, setId] = createSignal<string>()
|
||||
const current = createSessionResolution(id, () => fixture.sessions)
|
||||
|
||||
expect(current()).toBeUndefined()
|
||||
await flush()
|
||||
expect(fixture.resolves).toEqual([])
|
||||
expect(fixture.messages.syncs).toEqual([])
|
||||
|
||||
setId("ses_a")
|
||||
expect(fixture.resolves).toEqual(["ses_a"])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a"])
|
||||
|
||||
fixture.messages.resolve(undefined)
|
||||
await flush()
|
||||
expect(current()).toBeUndefined()
|
||||
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
expect(current()?.id).toBe("ses_a")
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("message failure does not fail metadata resolution", async () => {
|
||||
test("resolves an uncached session", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const fixture = createFixture()
|
||||
const current = createSessionResolution(
|
||||
@@ -94,10 +59,9 @@ test("message failure does not fail metadata resolution", async () => {
|
||||
() => fixture.sessions,
|
||||
)
|
||||
|
||||
await flush()
|
||||
fixture.messages.reject(new Error("message sync failed"))
|
||||
await flush()
|
||||
expect(current()).toBeUndefined()
|
||||
await flush()
|
||||
expect(fixture.resolves).toEqual(["ses_a"])
|
||||
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
@@ -118,15 +82,12 @@ test("re-resolves when navigating to an uncached session without a remount", asy
|
||||
|
||||
await flush()
|
||||
expect(current()?.id).toBe("ses_a")
|
||||
expect(fixture.resolves).toEqual([])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a"])
|
||||
|
||||
expect(() => {
|
||||
setId("ses_b")
|
||||
current()
|
||||
}).not.toThrow()
|
||||
expect(fixture.resolves).toEqual(["ses_b"])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b"])
|
||||
|
||||
fixture.settle("ses_b")
|
||||
await flush()
|
||||
@@ -178,7 +139,6 @@ test("returning to a pruned session re-resolves instead of throwing not found",
|
||||
current()
|
||||
}).not.toThrow()
|
||||
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
@@ -207,7 +167,6 @@ test("revisiting a session whose resolution failed while unfocused retries clean
|
||||
current()
|
||||
}).not.toThrow()
|
||||
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
@@ -238,8 +197,6 @@ test("re-resolves against a replaced session store", async () => {
|
||||
}).not.toThrow()
|
||||
await flush()
|
||||
expect(second.resolves).toEqual(["ses_a"])
|
||||
expect(first.messages.syncs).toEqual(["ses_a"])
|
||||
expect(second.messages.syncs).toEqual(["ses_a"])
|
||||
|
||||
second.settle("ses_a")
|
||||
await flush()
|
||||
|
||||
@@ -198,41 +198,3 @@ test("stale pinned indexes do not produce missing virtual items after count shri
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("snapshots materialize only measured rows and restore their current geometry", () => {
|
||||
const options = {
|
||||
count: 100,
|
||||
getItemKey: (index: number) => `row-${index}`,
|
||||
estimateSize: () => 60,
|
||||
getScrollElement: () => null,
|
||||
scrollToFn: () => {},
|
||||
observeElementRect: () => {},
|
||||
observeElementOffset: () => {},
|
||||
}
|
||||
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options)
|
||||
expect(virtualizer.getTotalSize()).toBe(6000)
|
||||
virtualizer.resizeItem(4, 100)
|
||||
virtualizer.resizeItem(99, 140)
|
||||
const measurements = virtualizer.getMeasurements()
|
||||
const reads: number[] = []
|
||||
virtualizer.getMeasurements = () =>
|
||||
new Proxy(measurements, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key === "string" && /^\d+$/.test(key)) reads.push(Number(key))
|
||||
return Reflect.get(target, key, receiver)
|
||||
},
|
||||
})
|
||||
|
||||
const snapshot = virtualizer.takeSnapshot()
|
||||
expect(reads).toEqual([4, 99])
|
||||
expect(snapshot).toEqual([
|
||||
{ index: 4, key: "row-4", start: 240, size: 100, end: 340, lane: 0 },
|
||||
{ index: 99, key: "row-99", start: 5980, size: 140, end: 6120, lane: 0 },
|
||||
])
|
||||
const restored = new Virtualizer<HTMLDivElement, HTMLDivElement>({ ...options, initialMeasurementsCache: snapshot })
|
||||
expect(restored.getTotalSize()).toBe(6120)
|
||||
expect(restored.takeSnapshot()).toEqual(snapshot)
|
||||
restored.setOptions({ ...options, count: 10 })
|
||||
restored.resizeItem(4, 80)
|
||||
expect(restored.takeSnapshot()).toEqual([{ index: 4, key: "row-4", start: 240, size: 80, end: 320, lane: 0 }])
|
||||
})
|
||||
|
||||
@@ -375,11 +375,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
cors: Flag.string("cors").pipe(
|
||||
Flag.withSchema(Schema.NonEmptyString),
|
||||
Flag.withDescription("Additional allowed CORS origin (repeat for multiple origins)"),
|
||||
Flag.atLeast(0),
|
||||
),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ export default Runtime.handler(
|
||||
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
|
||||
hostname: Option.getOrUndefined(input.hostname),
|
||||
port: Option.getOrUndefined(input.port),
|
||||
cors: input.cors.length > 0 ? input.cors : undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ export type Options = {
|
||||
readonly mode: Mode
|
||||
readonly hostname?: string
|
||||
readonly port?: number
|
||||
readonly cors?: readonly string[]
|
||||
}
|
||||
|
||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
||||
@@ -89,7 +88,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
},
|
||||
hostname,
|
||||
port,
|
||||
cors: options.cors ?? config.cors,
|
||||
password,
|
||||
pty: { handoff },
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
|
||||
@@ -15,12 +15,11 @@ export const Info = Schema.Struct({
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
cors: Schema.optional(Schema.Array(Schema.String)),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const keys = ["hostname", "port", "password", "cors", "env"] as const
|
||||
const keys = ["hostname", "port", "password", "env"] as const
|
||||
type Key = (typeof keys)[number]
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
@@ -78,7 +77,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
||||
})
|
||||
|
||||
function configKey(key: string): Key {
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
@@ -161,9 +160,6 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
|
||||
case "password": {
|
||||
return yield* password()
|
||||
}
|
||||
case "cors": {
|
||||
return JSON.stringify((yield* read()).cors ?? [], null, 2)
|
||||
}
|
||||
case "env": {
|
||||
const env = (yield* read()).env ?? {}
|
||||
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
|
||||
@@ -201,19 +197,6 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
|
||||
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
|
||||
return
|
||||
}
|
||||
case "cors": {
|
||||
const cors = value.split(",").map((origin) => origin.trim())
|
||||
if (
|
||||
cors.some((origin) => {
|
||||
const url = URL.parse(origin)
|
||||
return !url || (url.protocol !== "http:" && url.protocol !== "https:") || url.origin !== origin
|
||||
})
|
||||
)
|
||||
throw new Error("CORS must be a comma-separated list of HTTP(S) origins without paths or trailing slashes")
|
||||
yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), cors })
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -248,12 +231,6 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
|
||||
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
|
||||
return
|
||||
}
|
||||
case "cors": {
|
||||
yield* Service.stop(yield* options())
|
||||
const { cors: _cors, ...next } = yield* read()
|
||||
yield* write(next)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Exit, FileSystem } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import path from "node:path"
|
||||
import { Commands } from "../src/commands/commands"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
|
||||
it.live("service CORS config persists multiple origins and preserves other settings on set and unset", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-" })
|
||||
const config = path.join(root, "config")
|
||||
const state = path.join(root, "state")
|
||||
const file = path.join(config, ServiceConfig.filename())
|
||||
const existing = { hostname: "127.0.0.1", port: 4321, password: "test-secret", env: { TEST: "value" } }
|
||||
yield* fs.makeDirectory(config)
|
||||
yield* fs.makeDirectory(state)
|
||||
yield* fs.writeFileString(file, JSON.stringify(existing))
|
||||
yield* Effect.gen(function* () {
|
||||
expect(yield* ServiceConfig.get("cors")).toBe("[]")
|
||||
yield* ServiceConfig.set("cors", " http://192.0.2.10:3001, https://app.example.com ")
|
||||
const cors = ["http://192.0.2.10:3001", "https://app.example.com"]
|
||||
expect(yield* ServiceConfig.read()).toEqual({ ...existing, cors })
|
||||
expect(yield* ServiceConfig.get("cors")).toBe(JSON.stringify(cors, null, 2))
|
||||
expect(JSON.parse(yield* ServiceConfig.get())).toEqual({
|
||||
hostname: existing.hostname,
|
||||
port: existing.port,
|
||||
env: existing.env,
|
||||
cors,
|
||||
})
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual({ ...existing, cors })
|
||||
yield* ServiceConfig.set("cors", "https://replacement.example.com")
|
||||
expect((yield* ServiceConfig.read()).cors).toEqual(["https://replacement.example.com"])
|
||||
yield* ServiceConfig.unset("cors")
|
||||
expect(yield* ServiceConfig.get("cors")).toBe("[]")
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
|
||||
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
|
||||
}).pipe(Effect.provide(NodeServices.layer)),
|
||||
)
|
||||
|
||||
it.live("service CORS config rejects empty lists, invalid origins, and extra arguments without changing config", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-invalid-" })
|
||||
const config = path.join(root, "config")
|
||||
const state = path.join(root, "state")
|
||||
const file = path.join(config, ServiceConfig.filename())
|
||||
const existing = { port: 4321, cors: ["https://app.example.com"] }
|
||||
yield* fs.makeDirectory(config)
|
||||
yield* fs.makeDirectory(state)
|
||||
yield* fs.writeFileString(file, JSON.stringify(existing))
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
",",
|
||||
"https://app.example.com,",
|
||||
",https://app.example.com",
|
||||
"https://app.example.com,,https://other.example.com",
|
||||
"not-a-url",
|
||||
"*",
|
||||
"null",
|
||||
"ftp://app.example.com",
|
||||
"https://app.example.com/",
|
||||
"https://app.example.com/path",
|
||||
"https://app.example.com?query=1",
|
||||
"https://app.example.com#fragment",
|
||||
"https://user:password@app.example.com",
|
||||
],
|
||||
(value) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* ServiceConfig.set("cors", value).pipe(Effect.exit))).toBe(true)
|
||||
expect(yield* ServiceConfig.read()).toEqual(existing)
|
||||
}),
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
ServiceConfig.get("cors", "extra"),
|
||||
ServiceConfig.set("cors", "https://app.example.com", "extra"),
|
||||
ServiceConfig.unset("cors", "extra"),
|
||||
],
|
||||
(operation) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* operation.pipe(Effect.exit))).toBe(true)
|
||||
}),
|
||||
)
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
|
||||
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
|
||||
}).pipe(Effect.provide(NodeServices.layer)),
|
||||
)
|
||||
|
||||
test.each([
|
||||
{ args: [], cors: [] },
|
||||
{ args: ["--cors", "https://app.example.com"], cors: ["https://app.example.com"] },
|
||||
{
|
||||
args: ["--service", "--cors", "http://192.0.2.10:3001", "--cors", "https://app.example.com"],
|
||||
cors: ["http://192.0.2.10:3001", "https://app.example.com"],
|
||||
},
|
||||
])("serve parses CORS flags: $args", async ({ args, cors }) => {
|
||||
const received: (readonly string[])[] = []
|
||||
const command = Commands.commands.serve.spec.pipe(
|
||||
Command.withHandler((input) => Effect.sync(() => void received.push(input.cors))),
|
||||
)
|
||||
await Effect.runPromise(Command.runWith(command, { version: "test" })(args).pipe(Effect.provide(NodeServices.layer)))
|
||||
expect(received).toEqual([cors])
|
||||
})
|
||||
|
||||
test.each([{ args: ["--cors"] }, { args: ["--cors", ""] }])(
|
||||
"serve rejects a missing or empty CORS flag value: $args",
|
||||
async ({ args }) => {
|
||||
const command = Commands.commands.serve.spec.pipe(Command.withHandler(() => Effect.void))
|
||||
const result = await Effect.runPromise(
|
||||
Command.runWith(command, { version: "test", renderErrors: false })(args).pipe(
|
||||
Effect.exit,
|
||||
Effect.provide(NodeServices.layer),
|
||||
),
|
||||
)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
},
|
||||
)
|
||||
@@ -9,7 +9,6 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { ServiceRegistration } from "../src/services/service-registration"
|
||||
import { isolatedEnv } from "./fixture/environment"
|
||||
|
||||
test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||
@@ -314,48 +313,6 @@ test("configured managed service port overrides the channel default", async () =
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test.each([
|
||||
{ args: [], origins: ["http://192.0.2.10:3001", "https://configured.example.com"] },
|
||||
{
|
||||
args: ["--cors", "http://192.0.2.20:3001", "--cors", "https://override.example.com"],
|
||||
origins: ["http://192.0.2.20:3001", "https://override.example.com"],
|
||||
},
|
||||
])(
|
||||
"managed service applies CORS configuration with flag overrides: $args",
|
||||
async ({ args, origins }) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-cors-"))
|
||||
const config = path.join(root, "config", ServiceConfig.filename())
|
||||
const registration = path.join(root, "state", "opencode", ServiceConfig.filename())
|
||||
const cors = ["http://192.0.2.10:3001", "https://configured.example.com"]
|
||||
await fs.mkdir(path.dirname(config), { recursive: true })
|
||||
await fs.writeFile(config, JSON.stringify({ cors }))
|
||||
const owner = Bun.spawn(
|
||||
[process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service", "--port", "0", ...args],
|
||||
{ env: isolatedEnv(root), stderr: "pipe", stdout: "ignore" },
|
||||
)
|
||||
try {
|
||||
const info = await waitForInfo(registration)
|
||||
await Promise.all(
|
||||
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
|
||||
const response = await fetch(new URL("/api/health", info.url), {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
|
||||
})
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(
|
||||
origins.some((value) => value === origin) ? origin : null,
|
||||
)
|
||||
}),
|
||||
)
|
||||
expect((await Bun.file(config).json()).cors).toEqual(cors)
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
|
||||
test("unrelated managed port occupancy reports an actionable conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
|
||||
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
|
||||
|
||||
@@ -1950,12 +1950,6 @@ export type VcsGetInput = {
|
||||
export type VcsGetOutput = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
|
||||
|
||||
export type VcsBaseInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type VcsBaseOutput = { readonly location: Location.Info; readonly data: Vcs.Base | null }
|
||||
export type VcsBaseOperation<E = never> = (input?: VcsBaseInput) => Effect.Effect<VcsBaseOutput, E>
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1973,7 +1967,6 @@ export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effe
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type VcsDiffOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
@@ -1981,7 +1974,6 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly base: VcsBaseOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
readonly branches: VcsBranchesOperation<E>
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
|
||||
@@ -244,8 +244,6 @@ import type {
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
VcsBaseOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsBranchesInput,
|
||||
@@ -1470,11 +1468,6 @@ const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointVcsBase = (raw: RawClient["server.vcs"]) => (input?: VcsBaseInput) =>
|
||||
preserveEffect<VcsBaseOutput>()(
|
||||
raw["vcs.base"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusInput) =>
|
||||
preserveEffect<VcsStatusOutput>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1489,14 +1482,13 @@ const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranch
|
||||
|
||||
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
|
||||
preserveEffect<VcsDiffOutput>()(
|
||||
raw["vcs.diff"]({
|
||||
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
|
||||
get: EndpointVcsGet(raw),
|
||||
base: EndpointVcsBase(raw),
|
||||
status: EndpointVcsStatus(raw),
|
||||
branches: EndpointVcsBranches(raw),
|
||||
diff: EndpointVcsDiff(raw),
|
||||
|
||||
@@ -34,7 +34,6 @@ export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Vcs } from "@opencode-ai/schema/vcs"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
|
||||
@@ -240,8 +240,6 @@ import type {
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
VcsBaseOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsBranchesInput,
|
||||
@@ -1998,18 +1996,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
base: (input?: VcsBaseInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsBaseOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/base`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsStatusOutput>(
|
||||
{
|
||||
@@ -2039,9 +2025,9 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/diff`,
|
||||
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
|
||||
query: { location: input["location"], mode: input["mode"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -419,8 +419,6 @@ export type WorkspaceDestroyResult = { destroyed: boolean }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
export type VcsBase = { name: string; ref: string; source: "reflog" | "default" }
|
||||
|
||||
export type VcsFileStatus = {
|
||||
file: string
|
||||
additions: number
|
||||
@@ -6075,17 +6073,6 @@ export type VcsGetOutput = {
|
||||
data: VcsInfo
|
||||
}
|
||||
|
||||
export type VcsBaseInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type VcsBaseOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: VcsBase | null
|
||||
}
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -6123,26 +6110,17 @@ export type VcsBranchesOutput = {
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly context?: number | undefined
|
||||
}["location"]
|
||||
readonly mode: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly context?: number | undefined
|
||||
}["mode"]
|
||||
readonly base?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["base"]
|
||||
readonly context?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
@@ -750,8 +750,7 @@ export function createData(config: CreateDataInput) {
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Model } from "@opencode-ai/schema/model"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Vcs } from "@opencode-ai/schema/vcs"
|
||||
|
||||
const Client = await import("../src/effect")
|
||||
|
||||
@@ -15,7 +14,6 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Config).toBe(Config)
|
||||
expect(Client.Model).toBe(Model)
|
||||
expect(Client.Session).toBe(Session)
|
||||
expect(Client.Vcs.Base).toBe(Vcs.Base)
|
||||
})
|
||||
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
|
||||
@@ -27,26 +27,6 @@ test("health.get decodes the readiness response", async () => {
|
||||
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
|
||||
})
|
||||
|
||||
test("vcs.base decodes nullable review-base metadata", async () => {
|
||||
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
|
||||
const base = {
|
||||
name: "release",
|
||||
ref: "refs/remotes/origin/release",
|
||||
source: "reflog",
|
||||
}
|
||||
for (const data of [base, null]) {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ location, data }))),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.vcs.base({ location: { directory: AbsolutePath.make("/repo") } })
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
expect(result.data).toEqual(data)
|
||||
expect(result.location.directory).toBe("/repo")
|
||||
}
|
||||
})
|
||||
|
||||
test("session.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
||||
|
||||
@@ -47,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
@@ -84,46 +84,6 @@ test("config.get returns ordered config entries for a location", async () => {
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("vcs.base and committed diffs preserve location and explicit base on the wire", async () => {
|
||||
const requests: Request[] = []
|
||||
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
|
||||
const base = { name: "release", ref: "refs/remotes/origin/release", source: "reflog" }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
return Response.json({ location, data: new URL(request.url).pathname.endsWith("/base") ? base : [] })
|
||||
},
|
||||
})
|
||||
expect(await client.vcs.base({ location: { directory: "/repo" } })).toEqual({ location, data: base })
|
||||
expect(
|
||||
await client.vcs.diff({ location: { directory: "/repo" }, mode: "committed", base: base.ref, context: 1 }),
|
||||
).toEqual({ location, data: [] })
|
||||
expect(new URL(requests[0].url).pathname).toBe("/api/vcs/base")
|
||||
const query = new URL(requests[1].url).searchParams
|
||||
expect(query.get("location[directory]")).toBe("/repo")
|
||||
expect(query.get("mode")).toBe("committed")
|
||||
expect(query.get("base")).toBe(base.ref)
|
||||
expect(query.get("context")).toBe("1")
|
||||
})
|
||||
|
||||
test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
Response.json(
|
||||
{ _tag: "ServiceUnavailableError", service: "vcs", message: "No review base available" },
|
||||
{ status: 503 },
|
||||
),
|
||||
})
|
||||
await expect(client.vcs.diff({ mode: "committed" })).rejects.toMatchObject({
|
||||
_tag: "ServiceUnavailableError",
|
||||
service: "vcs",
|
||||
message: "No review base available",
|
||||
})
|
||||
})
|
||||
|
||||
test("project.update uses the global project contract", async () => {
|
||||
let request: Request | undefined
|
||||
const project = {
|
||||
|
||||
@@ -666,36 +666,6 @@ test("ignores activity snapshots from an older connection", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("projects background user shell metadata from durable shell data", () => {
|
||||
const setup = activityFixture(() => Response.json({ data: {} }))
|
||||
try {
|
||||
setup.emit({
|
||||
id: "evt_user_shell",
|
||||
created: 1,
|
||||
type: "session.shell.started",
|
||||
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_refresh",
|
||||
shell: {
|
||||
id: "sh_user",
|
||||
status: "running",
|
||||
command: "pwd",
|
||||
cwd: "/project",
|
||||
shell: "/bin/sh",
|
||||
file: "/project/shell.out",
|
||||
metadata: { sessionID: "ses_refresh", background: true },
|
||||
time: { started: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(setup.data.session.message.list("ses_refresh")).toMatchObject([
|
||||
{ type: "shell", shellID: "sh_user", status: "running", metadata: { background: true } },
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function activityFixture(read: () => Response | Promise<Response>) {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
@@ -24,8 +24,8 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
|
||||
const response = yield* client
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause),
|
||||
Effect.catch((cause) =>
|
||||
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
|
||||
),
|
||||
)
|
||||
const text = yield* readResponseBody(response, plan)
|
||||
|
||||
@@ -316,15 +316,16 @@ export const layer = (options?: Options) =>
|
||||
}
|
||||
})
|
||||
|
||||
const reload = Effect.fn("Config.reload")(
|
||||
function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
},
|
||||
(effect) => reloadLock.withPermit(effect),
|
||||
const reload = Effect.fn("Config.reload")(() =>
|
||||
reloadLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(updates).pipe(
|
||||
|
||||
@@ -292,13 +292,18 @@ function normalizeMcpTimeout(
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
const recognized = Object.entries(ConfigMCP.Timeout.fields).filter(([key]) => own(value, key))
|
||||
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
||||
if (Object.keys(value).length && !recognized.length) {
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
recognized.forEach(([key, field]) => {
|
||||
const leaf = decodeEncoded(field, value[key], [...path, key], diagnostics)
|
||||
recognized.forEach((key) => {
|
||||
const leaf = decodeEncoded(
|
||||
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
||||
value[key],
|
||||
[...path, key],
|
||||
diagnostics,
|
||||
)
|
||||
if (leaf === undefined) return
|
||||
overlay(timeout, key, leaf, [...path, key], diagnostics)
|
||||
})
|
||||
|
||||
@@ -32,7 +32,19 @@ type PathAction =
|
||||
| typeof ReadTool.name
|
||||
| typeof EditTool.name
|
||||
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
|
||||
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
|
||||
const agentKeys = new Set([
|
||||
"model",
|
||||
"variant",
|
||||
"request",
|
||||
"system",
|
||||
"description",
|
||||
"mode",
|
||||
"hidden",
|
||||
"color",
|
||||
"steps",
|
||||
"disabled",
|
||||
"permissions",
|
||||
])
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.agent",
|
||||
|
||||
@@ -83,25 +83,26 @@ export const Plugin = define({
|
||||
),
|
||||
)
|
||||
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(
|
||||
function* (file?: string) {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
},
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
|
||||
@@ -151,25 +151,26 @@ export const Plugin = define({
|
||||
return skills
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(
|
||||
function* (file?: string) {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
},
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
|
||||
|
||||
@@ -227,7 +227,7 @@ export class SQLiteEffectInsertBase<
|
||||
config: SQLiteInsertConfig<TTable>
|
||||
|
||||
constructor(
|
||||
table: TTable,
|
||||
private table: TTable,
|
||||
values: SQLiteInsertConfig["values"],
|
||||
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
|
||||
private effectDialect: SQLiteDialect,
|
||||
|
||||
@@ -277,7 +277,11 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
|
||||
assertUnreachable(cacheStrat)
|
||||
}).pipe(Effect.mapError((e) => new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })))
|
||||
}).pipe(
|
||||
Effect.catch((e) => {
|
||||
return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
getQuery(): Query {
|
||||
|
||||
+33
-28
@@ -177,7 +177,7 @@ const layer = Layer.effect(
|
||||
if (!dotgit) return undefined
|
||||
|
||||
const cwd = path.dirname(dotgit)
|
||||
const result = yield* run(cwd, proc, ["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/)
|
||||
if (!gitDir || !commonDir) return undefined
|
||||
|
||||
@@ -189,13 +189,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
|
||||
const result = yield* run(repository.worktree, proc, ["remote", "get-url", name])
|
||||
const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc, ["rev-list", "--max-parents=0", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text
|
||||
.split("\n")
|
||||
@@ -205,13 +205,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc, ["rev-parse", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc, ["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
@@ -220,7 +220,7 @@ const layer = Layer.effect(
|
||||
repository: Repository,
|
||||
remoteName = "origin",
|
||||
) {
|
||||
const result = yield* run(repository.worktree, proc, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
|
||||
})
|
||||
@@ -230,7 +230,10 @@ const layer = Layer.effect(
|
||||
directory: AbsolutePath,
|
||||
args: string[],
|
||||
) {
|
||||
const result = yield* execute(directory, proc, args).pipe(
|
||||
const result = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(args).pipe(
|
||||
Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
@@ -708,29 +711,31 @@ interface Result {
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
return execute(cwd, proc, args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
|
||||
function run(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
|
||||
}
|
||||
|
||||
function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
return proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
function execute(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type LanguageModelV3,
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3Content,
|
||||
type LanguageModelV3ProviderTool,
|
||||
type LanguageModelV3StreamPart,
|
||||
type SharedV3ProviderMetadata,
|
||||
type SharedV3Warning,
|
||||
@@ -26,7 +27,7 @@ import { imageGenerationOutputSchema } from "./tool/image-generation.js"
|
||||
import { convertToOpenAIResponsesInput } from "./convert-to-openai-responses-input.js"
|
||||
import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-reason.js"
|
||||
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
|
||||
import { prepareResponsesTools, type ResponsesHostedTool } from "./openai-responses-prepare-tools.js"
|
||||
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
|
||||
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
|
||||
|
||||
const webSearchCallItem = z.object({
|
||||
@@ -220,23 +221,15 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
addInclude("message.output_text.logprobs")
|
||||
}
|
||||
|
||||
const {
|
||||
tools: openaiTools,
|
||||
toolChoice: openaiToolChoice,
|
||||
hostedTools,
|
||||
selectedHostedTool,
|
||||
toolWarnings,
|
||||
} = prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
strictJsonSchema,
|
||||
})
|
||||
const getHostedToolName = (responseType: ResponsesHostedTool["responseType"]) => {
|
||||
if (selectedHostedTool?.responseType === responseType) return selectedHostedTool.name
|
||||
return hostedTools.find((tool) => tool.responseType === responseType)?.name ?? responseType
|
||||
}
|
||||
// when a web search tool is present, automatically include the sources:
|
||||
const webSearchToolName = (
|
||||
tools?.find(
|
||||
(tool) =>
|
||||
tool.type === "provider" && (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"),
|
||||
) as LanguageModelV3ProviderTool | undefined
|
||||
)?.name
|
||||
|
||||
if (hostedTools.some((tool) => tool.responseType === "web_search")) {
|
||||
if (webSearchToolName) {
|
||||
addInclude("web_search_call.action.sources")
|
||||
}
|
||||
|
||||
@@ -364,8 +357,18 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
baseArgs.service_tier = undefined
|
||||
}
|
||||
|
||||
const {
|
||||
tools: openaiTools,
|
||||
toolChoice: openaiToolChoice,
|
||||
toolWarnings,
|
||||
} = prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
strictJsonSchema,
|
||||
})
|
||||
|
||||
return {
|
||||
getHostedToolName,
|
||||
webSearchToolName,
|
||||
args: {
|
||||
...baseArgs,
|
||||
tools: openaiTools,
|
||||
@@ -376,7 +379,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
async doGenerate(options: LanguageModelV3CallOptions) {
|
||||
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
|
||||
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
|
||||
const url = this.config.url({
|
||||
path: "/responses",
|
||||
modelId: this.modelId,
|
||||
@@ -523,7 +526,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
toolName: "image_generation",
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -531,7 +534,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
toolName: "image_generation",
|
||||
result: {
|
||||
result: part.result,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -602,7 +605,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: getHostedToolName("web_search"),
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
input: JSON.stringify({ action: part.action }),
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -610,7 +613,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: getHostedToolName("web_search"),
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
result: { status: part.status },
|
||||
})
|
||||
|
||||
@@ -642,7 +645,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: getHostedToolName("file_search"),
|
||||
toolName: "file_search",
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -650,7 +653,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: getHostedToolName("file_search"),
|
||||
toolName: "file_search",
|
||||
result: {
|
||||
queries: part.queries,
|
||||
results:
|
||||
@@ -670,7 +673,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
toolName: "code_interpreter",
|
||||
input: JSON.stringify({
|
||||
code: part.code,
|
||||
containerId: part.container_id,
|
||||
@@ -681,7 +684,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
toolName: "code_interpreter",
|
||||
result: {
|
||||
outputs: part.outputs,
|
||||
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
|
||||
@@ -743,7 +746,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
async doStream(options: LanguageModelV3CallOptions) {
|
||||
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
|
||||
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
|
||||
|
||||
const { responseHeaders, value: response } = await postJsonToApi({
|
||||
url: this.config.url({
|
||||
@@ -863,7 +866,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
id: value.item.id,
|
||||
toolName: getHostedToolName("web_search"),
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
})
|
||||
} else if (value.item.type === "computer_call") {
|
||||
ongoingToolCalls[value.output_index] = {
|
||||
@@ -886,7 +889,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
id: value.item.id,
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
toolName: "code_interpreter",
|
||||
})
|
||||
|
||||
controller.enqueue({
|
||||
@@ -898,7 +901,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: getHostedToolName("file_search"),
|
||||
toolName: "file_search",
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -906,7 +909,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
toolName: "image_generation",
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -977,7 +980,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: getHostedToolName("web_search"),
|
||||
toolName: "web_search",
|
||||
input: JSON.stringify({ action: value.item.action }),
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -985,7 +988,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: getHostedToolName("web_search"),
|
||||
toolName: "web_search",
|
||||
result: { status: value.item.status },
|
||||
})
|
||||
} else if (value.item.type === "computer_call") {
|
||||
@@ -1019,7 +1022,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: getHostedToolName("file_search"),
|
||||
toolName: "file_search",
|
||||
result: {
|
||||
queries: value.item.queries,
|
||||
results:
|
||||
@@ -1038,7 +1041,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
toolName: "code_interpreter",
|
||||
result: {
|
||||
outputs: value.item.outputs,
|
||||
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
|
||||
@@ -1047,7 +1050,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
toolName: "image_generation",
|
||||
result: {
|
||||
result: value.item.result,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -1096,7 +1099,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item_id,
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
toolName: "image_generation",
|
||||
result: {
|
||||
result: value.partial_image_b64,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -1132,7 +1135,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
toolName: "code_interpreter",
|
||||
input: JSON.stringify({
|
||||
code: value.code,
|
||||
containerId: toolCall.codeInterpreter!.containerId,
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3ProviderTool,
|
||||
type SharedV3Warning,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
|
||||
import { codeInterpreterArgsSchema } from "./tool/code-interpreter.js"
|
||||
import { fileSearchArgsSchema } from "./tool/file-search.js"
|
||||
import { webSearchArgsSchema } from "./tool/web-search.js"
|
||||
@@ -11,28 +6,6 @@ import { webSearchPreviewArgsSchema } from "./tool/web-search-preview.js"
|
||||
import { imageGenerationArgsSchema } from "./tool/image-generation.js"
|
||||
import type { OpenAIResponsesTool } from "./openai-responses-api-types.js"
|
||||
|
||||
export type ResponsesHostedTool = {
|
||||
name: string
|
||||
type: "file_search" | "web_search_preview" | "web_search" | "code_interpreter" | "image_generation"
|
||||
responseType: "file_search" | "web_search" | "code_interpreter" | "image_generation"
|
||||
}
|
||||
|
||||
export function getResponsesHostedTool(tool: LanguageModelV3ProviderTool): ResponsesHostedTool | undefined {
|
||||
switch (tool.id) {
|
||||
case "openai.file_search":
|
||||
return { name: tool.name, type: "file_search", responseType: "file_search" }
|
||||
case "openai.web_search_preview":
|
||||
return { name: tool.name, type: "web_search_preview", responseType: "web_search" }
|
||||
case "openai.web_search":
|
||||
return { name: tool.name, type: "web_search", responseType: "web_search" }
|
||||
case "openai.code_interpreter":
|
||||
return { name: tool.name, type: "code_interpreter", responseType: "code_interpreter" }
|
||||
case "openai.image_generation":
|
||||
return { name: tool.name, type: "image_generation", responseType: "image_generation" }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
@@ -53,8 +26,6 @@ export function prepareResponsesTools({
|
||||
| { type: "function"; name: string }
|
||||
| { type: "code_interpreter" }
|
||||
| { type: "image_generation" }
|
||||
hostedTools: ResponsesHostedTool[]
|
||||
selectedHostedTool?: ResponsesHostedTool
|
||||
toolWarnings: SharedV3Warning[]
|
||||
} {
|
||||
// when the tools array is empty, change it to undefined to prevent errors:
|
||||
@@ -63,49 +34,7 @@ export function prepareResponsesTools({
|
||||
const toolWarnings: SharedV3Warning[] = []
|
||||
|
||||
if (tools == null) {
|
||||
return { tools: undefined, toolChoice: undefined, hostedTools: [], toolWarnings }
|
||||
}
|
||||
|
||||
const hostedTools = tools.flatMap((tool) => {
|
||||
if (tool.type !== "provider") return []
|
||||
const hostedTool = getResponsesHostedTool(tool)
|
||||
return hostedTool ? [hostedTool] : []
|
||||
})
|
||||
const selectedToolName = toolChoice?.type === "tool" ? toolChoice.toolName : undefined
|
||||
const selectedTools = selectedToolName === undefined ? [] : tools.filter((tool) => tool.name === selectedToolName)
|
||||
if (selectedTools.length > 1) {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous tool choice '${selectedToolName}': multiple tool definitions share this name`,
|
||||
})
|
||||
}
|
||||
const selectedHostedTool =
|
||||
selectedTools[0]?.type === "provider" ? getResponsesHostedTool(selectedTools[0]) : undefined
|
||||
|
||||
const ambiguousHostedResponse =
|
||||
toolChoice?.type === "none" || toolChoice?.type === "tool"
|
||||
? undefined
|
||||
: hostedTools.find(
|
||||
(tool) =>
|
||||
new Set(
|
||||
hostedTools.filter((candidate) => candidate.responseType === tool.responseType).map((item) => item.name),
|
||||
).size > 1,
|
||||
)
|
||||
if (ambiguousHostedResponse) {
|
||||
const names = new Set(
|
||||
hostedTools.filter((tool) => tool.responseType === ambiguousHostedResponse.responseType).map((tool) => tool.name),
|
||||
)
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous ${ambiguousHostedResponse.responseType} response for hosted tools: ${[...names].join(", ")}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedHostedTool) {
|
||||
const names = new Set(hostedTools.filter((tool) => tool.type === selectedHostedTool.type).map((tool) => tool.name))
|
||||
if (names.size > 1) {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous ${selectedHostedTool.type} tool choice for hosted tools: ${[...names].join(", ")}`,
|
||||
})
|
||||
}
|
||||
return { tools: undefined, toolChoice: undefined, toolWarnings }
|
||||
}
|
||||
|
||||
const openaiTools: Array<OpenAIResponsesTool> = []
|
||||
@@ -205,7 +134,7 @@ export function prepareResponsesTools({
|
||||
}
|
||||
|
||||
if (toolChoice == null) {
|
||||
return { tools: openaiTools, toolChoice: undefined, hostedTools, selectedHostedTool, toolWarnings }
|
||||
return { tools: openaiTools, toolChoice: undefined, toolWarnings }
|
||||
}
|
||||
|
||||
const type = toolChoice.type
|
||||
@@ -214,18 +143,20 @@ export function prepareResponsesTools({
|
||||
case "auto":
|
||||
case "none":
|
||||
case "required":
|
||||
return { tools: openaiTools, toolChoice: type, hostedTools, selectedHostedTool, toolWarnings }
|
||||
case "tool": {
|
||||
return { tools: openaiTools, toolChoice: type, toolWarnings }
|
||||
case "tool":
|
||||
return {
|
||||
tools: openaiTools,
|
||||
toolChoice: selectedHostedTool
|
||||
? { type: selectedHostedTool.type }
|
||||
: { type: "function", name: toolChoice.toolName },
|
||||
hostedTools,
|
||||
selectedHostedTool,
|
||||
toolChoice:
|
||||
toolChoice.toolName === "code_interpreter" ||
|
||||
toolChoice.toolName === "file_search" ||
|
||||
toolChoice.toolName === "image_generation" ||
|
||||
toolChoice.toolName === "web_search_preview" ||
|
||||
toolChoice.toolName === "web_search"
|
||||
? { type: toolChoice.toolName }
|
||||
: { type: "function", name: toolChoice.toolName },
|
||||
toolWarnings,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = type
|
||||
throw new UnsupportedFunctionalityError({
|
||||
|
||||
@@ -78,15 +78,9 @@ export const make = Effect.gen(function* () {
|
||||
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
|
||||
]
|
||||
for (const [mime, encode] of encoders) {
|
||||
const candidate = encode()
|
||||
// Base64 uses four bytes per three input bytes, including padding.
|
||||
if (Math.ceil(candidate.length / 3) * 4 <= limits.maxBase64Bytes)
|
||||
return {
|
||||
...content,
|
||||
content: Buffer.from(candidate).toString("base64"),
|
||||
encoding: "base64" as const,
|
||||
mime,
|
||||
}
|
||||
const candidate = Buffer.from(encode()).toString("base64")
|
||||
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
|
||||
return { ...content, content: candidate, encoding: "base64" as const, mime }
|
||||
}
|
||||
} finally {
|
||||
resized.free()
|
||||
|
||||
@@ -45,8 +45,6 @@ import { InstructionBuiltIns } from "./instructions/builtins.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { SessionInstructions } from "./session/instructions.js"
|
||||
import { SessionGenerateNode } from "./session/generate-node.js"
|
||||
import { SessionPromptNode } from "./session/prompt-node.js"
|
||||
import { SessionRevertNode } from "./session/revert-node.js"
|
||||
import { McpTool } from "./tool/mcp.js"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
@@ -94,8 +92,6 @@ const nodes = [
|
||||
Form.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
SessionPromptNode.node,
|
||||
SessionRevertNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
|
||||
@@ -2,13 +2,13 @@ export * as InstructionBuiltIns from "./builtins.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location.js"
|
||||
import { SessionSchema } from "../session/schema.js"
|
||||
import { Instructions } from "./index.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
|
||||
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
|
||||
|
||||
@@ -378,109 +378,117 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.exit,
|
||||
)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}, Effect.uninterruptible)
|
||||
yield* Effect.gen(function* () {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.exit,
|
||||
)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<string, unknown>) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
|
||||
const persistence = yield* createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
const persistence = yield* createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
}, Effect.uninterruptible)
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const scrub = Effect.fnUntraced(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
|
||||
@@ -636,8 +636,8 @@ export const layer = (options?: Options) =>
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string, digest = bodyDigest(text)) {
|
||||
yield* kv.set(key, { updatedAt: Date.now(), digest, body: text }).pipe(
|
||||
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
|
||||
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
@@ -681,13 +681,12 @@ export const layer = (options?: Options) =>
|
||||
const stored = yield* loadFromCache()
|
||||
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
|
||||
const text = yield* fetchApi()
|
||||
const digest = bodyDigest(text)
|
||||
// models.dev rarely changes between polls; skip the cache write,
|
||||
// invalidation, and Refreshed event for a byte-identical body so
|
||||
// downstream catalog.updated listeners stay quiet.
|
||||
if (!force && stored?.digest === digest) return
|
||||
if (!force && stored?.digest === bodyDigest(text)) return
|
||||
yield* decodeCatalog(text)
|
||||
yield* writeCache(text, digest)
|
||||
yield* writeCache(text)
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
|
||||
@@ -292,11 +292,20 @@ const layer = Layer.effect(
|
||||
pending.delete(input.requestID)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
|
||||
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
|
||||
)
|
||||
if (result?.effect !== "allow") continue
|
||||
if (!rules) continue
|
||||
if (denied(item.request, rules)) continue
|
||||
const effective = [...rules, ...rememberedRules]
|
||||
if (
|
||||
!item.request.resources.every(
|
||||
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
|
||||
)
|
||||
)
|
||||
continue
|
||||
yield* bus.publish(Permission.Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
|
||||
@@ -2,9 +2,9 @@ export * as PermissionSaved from "./saved.js"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Project } from "../project.js"
|
||||
import { PermissionTable } from "./sql.js"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import type { Project } from "@opencode-ai/schema/project"
|
||||
import { Timestamps } from "../database/schema.sql.js"
|
||||
import { Project } from "../project.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
import type { PermissionSaved } from "./saved.js"
|
||||
|
||||
|
||||
@@ -369,10 +369,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
},
|
||||
vcs: {
|
||||
get: () => response(vcs.info()),
|
||||
base: () => response(vcs.base()),
|
||||
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
|
||||
status: () => response(vcs.status()),
|
||||
diff: (input) => response(vcs.diff(input.mode, { context: input.context, base: input.base })),
|
||||
diff: (input) => response(vcs.diff(input.mode, { context: input.context })),
|
||||
transform: vcs.transform,
|
||||
reload: vcs.reload,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "opencode.models.dev",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user