Compare commits

...
Author SHA1 Message Date
Kit Langton 91802603ea chore(tui): pin OpenTUI preview 2026-08-28 17:05:30 -04:00
Kit Langton 8089091e82 fix(tui): keep large diffs responsive 2026-08-28 17:05:17 -04:00
Kit Langton 0a718be0d9 refactor(plugin): normalize web search API type (#45717) 2026-08-28 16:55:32 -04:00
Kit Langton 67845091ba fix(util): share streamed stderr consumption (#45716) 2026-08-28 16:55:28 -04:00
Kit Langton fe788b7842 fix(core): use runtime update columns (#45700) 2026-08-28 16:55:24 -04:00
Kit Langton ee42eb3ca3 fix(core): render built-in command arguments (#45697) 2026-08-28 16:55:21 -04:00
Kit Langton 80323a4deb test(core): name acquired test services (#45696) 2026-08-28 16:55:17 -04:00
Kit Langton a35f96f427 fix(core): preserve late opaque reasoning (#45694) 2026-08-28 16:55:13 -04:00
Kit Langton d354c3d640 fix(server): avoid loading locations for pending reads (#45994) 2026-08-28 20:44:48 +00:00
Kit Langton ce005ce002 refactor(core): remove unused result plumbing (#45695) 2026-08-28 16:44:27 -04:00
Kit Langton 964245bc2a test(core): isolate project adoption fixture (#46009) 2026-08-28 16:18:36 -04:00
Kit Langton aea3e7c1d2 fix(core): refresh plugins in background (#45993) 2026-08-28 19:43:12 +00:00
Kit Langton 3625942952 feat(core): pass session context to MCP tools (#46008) 2026-08-28 15:43:06 -04:00
Kit Langton 0593a6b8eb fix(core): isolate models seed replay (#45686) 2026-08-28 15:26:22 -04:00
Kit Langton 426e5c6389 fix(tui): open recent picker before server reads (#45977)
Open the recent-session and project picker synchronously with selectable cached rows and independent refreshes. Reconcile committed moves and deletions without restoring stale rows, preserve dismissal and selection through delayed reads, and keep filtered selections visible after asynchronous results arrive.
2026-08-28 15:18:24 -04:00
Kit Langton ebdfcf4866 test(tui): remove brittle animation sampling (#46003) 2026-08-28 19:16:47 +00:00
Kit Langton 000d0882c3 docs(core): record adapter fork boundaries (#45691) 2026-08-28 15:14:46 -04:00
Kit Langton 6062e30cb9 test(core): assert typed failures directly (#45690) 2026-08-28 15:14:42 -04:00
Kit Langton 3badee1a3c test(core): simplify config test setup (#45689) 2026-08-28 15:14:39 -04:00
Kit Langton 4a0256d374 test(core): use effect search harness (#45682) 2026-08-28 15:14:36 -04:00
Kit Langton 52ec62bef0 docs: correct config discovery boundary (#45681) 2026-08-28 15:14:32 -04:00
Kit Langton 31af9858fd test(core): assert pending prompt identity (#45680) 2026-08-28 15:14:28 -04:00
Kit Langton 3151660fbb refactor(core): name shell records as commands (#45693) 2026-08-28 15:00:37 -04:00
Kit Langton 0362ef48ff test(core): isolate transport metrics (#45688) 2026-08-28 15:00:33 -04:00
Kit Langton facd7ff452 refactor(core): simplify V1 migration effects (#45685) 2026-08-28 15:00:29 -04:00
Kit Langton 134cdda333 fix(core): normalize SDK file data (#45679) 2026-08-28 14:56:46 -04:00
Kit Langton 5634ef1bb6 refactor(core): simplify manual compaction (#45678) 2026-08-28 14:56:40 -04:00
Kit Langton 2379ab3d51 fix(core): defer memory filesystem observation (#45675) 2026-08-28 14:56:36 -04:00
Kit Langton 5c908ebba5 refactor(core): simplify reference config precedence (#45673) 2026-08-28 14:56:31 -04:00
Kit Langton ba0755d933 refactor(core): reuse platform contract types (#45666) 2026-08-28 14:56:27 -04:00
Kit Langton f7d6b00c1e test(core): use collected arrays directly (#45665) 2026-08-28 14:56:21 -04:00
90 changed files with 2971 additions and 1273 deletions
+44 -14
View File
@@ -1073,9 +1073,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -2212,27 +2212,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
"@opentui/core": ["@opentui/core@https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-darwin-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-iJKwj+aq0w0TYZu0xafb5Nj5eBObZcKc4+qHCpj5EAQBRCid3Z8nROmiwdO+aDDeqwTYzfG53u+MU3urEtiweQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-8Wo6B6VhFXu5Lq/HDXQCkxCJF4svJ3OPU+WcotQJuLT9/pXzik40WjTKVn3HECKiqQ3ryQS4ZxpnJIxlNDcpmg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-Tt+qGxp5VJZKmw54XdzebE6a+0QVA5Sk8KWUDHfWXn4ley6tvpMZowrG2/O6TpKHP6XdA/bHm+Ewetyk7BO2Zg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-p62IQHNFFQUzet57HMQndNQZtunDc0peSHudBoatCTV68QYKsD5gYjAK/BW1XY0zUoJM9Jk7BJnPBJPv1s5EYQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-qvQgkAdLSa1+SeMhMNBZ7zx7OlVi8yMsV+DDGTDrcc9sWXDzGJEKUq/R9XqqAag64Sloc12YA5oUYDKKj4E2VA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-JIkwBP6yLhh4ViTR53kx/NCY5w1ocTcKMdWidpObKLVLIkBat97y085teSvgkh+RFuJnUaJK7114XgdEONCjSg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-M1BPZZ7eLE/nfnS7uypyIxg5JKfkkZSkehszISrQOf8zQl3+j3kOPy17RqSCJKZboGFQwmg8rwlaxGNxA6sfVQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-GFQ1GCA4FQg/MVpO1jt53X4fTaYsQTfW1EkOpQqmt/Rvls/oSklpElxh4ww5/giHEVnNxvcqX8+RG9U5DCmLpg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-NKFUOLwBfd59koYiPgeqrP4hZERnG6B7+RnH2DNO3dAnIAl/7dISuOwWw/VacYBck4ToG0ZrbhBMTeBB2i5DqQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
"@opentui/keymap": ["@opentui/keymap@https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Lw+wxniAxIqnZz9vFPwl8EsH33HZGnyukQTU/4dI01BwNKbx2hcxuFMjrHFP/MkqhvwmFXcvGSGfJrG8VLwFoA=="],
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
"@opentui/solid": ["@opentui/solid@https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-wtq+offTD/Hxd9KXW5eVjeBgtBiJiXakkk1Jo/8ULX2fvG+trtdaAHr0DrlHsRv/xyUMfxPBaHRwPe4Gk8+8sw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -6564,6 +6564,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -7450,6 +7454,30 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
"opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"opentui-spinner/@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8248,6 +8276,8 @@
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+3 -3
View File
@@ -49,9 +49,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
@@ -1367,7 +1367,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
@@ -1496,7 +1496,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
+2
View File
@@ -1,5 +1,7 @@
/// <reference types="@solidjs/start/env" />
import "@solidjs/start"
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
+12 -4
View File
@@ -482,8 +482,7 @@ function toolMessage(input: LLMRequest["messages"][number]) {
const value = part.result.value.filter((item) => {
if (item.type !== "file") return true
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
return false
})
return toolResultPart({
@@ -507,7 +506,7 @@ function text(part: ContentPart) {
function userPart(part: ContentPart): UserContent {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "media")
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return []
}
@@ -516,7 +515,7 @@ function assistantPart(part: ContentPart): AssistantContent {
case "text":
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
case "reasoning":
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "tool-call":
@@ -535,6 +534,15 @@ function assistantPart(part: ContentPart): AssistantContent {
}
}
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
if (typeof data !== "string") return data
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
if (base64 !== undefined) return base64
if (!URL.canParse(data)) return data
const url = new URL(data)
return url.protocol === "http:" || url.protocol === "https:" ? url : data
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
if (part.type !== "tool-result") return []
return [
+1 -5
View File
@@ -859,16 +859,12 @@ export function configured(options?: Options) {
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(Stream.concat(Stream.make(marker)))
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
Stream.filter((target) => target > sequence),
Stream.flatMap((target) => readThrough(target)),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, live)
}),
+1 -3
View File
@@ -20,14 +20,13 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
draft.add(
name,
local(entry)
? Reference.LocalSource.make({
@@ -48,7 +47,6 @@ export const Plugin = define({
)
}
}
for (const [name, source] of entries) draft.add(name, source)
})
}),
})
@@ -0,0 +1,35 @@
# Effect Drizzle SQLite Adapter
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
builders over Effect's generic `SqlClient`. It is maintained source, not
generated output.
## Provenance
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
SQLite Effect query builders, and shared query-builder utilities. The
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
The exact upstream revision originally copied into this repository is unknown.
The currently pinned `drizzle-orm` version is a compatibility dependency, not
copy provenance.
## Local Boundary
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
facade from `database/database.ts`.
Material local adaptations include:
- a runtime-independent driver over Effect's generic `SqlClient`
- local cache, mapping, and runtime-inspection helpers
- suppressed statement tracing beneath the database operation boundary
- explicit SQLite transactions and savepoints
- native transaction delegation for Durable Object SQLite
- deliberate query-builder variance annotations
Preserve these adaptations when comparing or synchronizing upstream code.
Focused regression coverage is in `test/database-drizzle.test.ts` and
`test/sqlite-workerd.test.ts`.
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
*
* @example
* ```ts
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
* import { SqliteClient } from "@effect/sql-sqlite-node"
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
* import { Effect } from "effect"
*
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
* )
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
: undefined
on = on(
new Proxy(
this.config.table._.columns,
getTableColumnsRuntime(this.config.table),
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
+129 -132
View File
@@ -481,7 +481,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
})
}
export const layer = Layer.effectDiscard(
@@ -521,76 +521,75 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
@@ -605,81 +604,79 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
.run(),
)
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
}).pipe(Effect.orDie),
)
}
@@ -708,7 +705,7 @@ function countNextSessions(sourcePath: string | undefined) {
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
)
}
function importNextDatabase(
+55 -52
View File
@@ -61,21 +61,23 @@ export const makeMemoryDriver = (): MemoryDriver => {
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
stat: (value) =>
Effect.suspend(() => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
}),
read: (value, range) =>
Effect.gen(function* () {
const original = lookup(value)
if (!original) return yield* new NotFound({ path: value })
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return { info: info(node), bytes: bytes.slice() }
}),
write: (value, bytes) =>
Effect.try({
try: () => {
@@ -89,17 +91,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
list: (value) =>
Effect.gen(function* () {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
return [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
}),
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
@@ -107,32 +109,33 @@ export const makeMemoryDriver = (): MemoryDriver => {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
move: (from, to) =>
Effect.gen(function* () {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return yield* new NotFound({ path: from })
yield* Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
}),
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
+36 -3
View File
@@ -1,5 +1,38 @@
This is a temporary package used primarily for GitHub Copilot compatibility.
# GitHub Copilot AI SDK Adapters
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
This directory contains upstream-derived AI SDK implementations adapted for
GitHub Copilot. It is not a generic OpenAI-compatible provider.
Avoid making edits to these files
## Provenance
- `chat/` is derived from the Vercel AI SDK
`@ai-sdk/openai-compatible` chat implementation.
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
implementation.
- The exact upstream revisions originally copied into this repository are
unknown. Current dependency versions and the `VERSION` constant in
`copilot-provider.ts` are not copy provenance.
## Ownership
Keep `chat/` and `responses/` structurally close to their upstream modules, but
preserve the intentional Copilot adaptations: the `copilot` options and metadata
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
Responses requests with encrypted reasoning, rotating response item IDs, and
explicit function-tool strictness taking precedence over the global fallback.
`copilot-provider.ts` is the local adapter assembly entrypoint used by
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
reconciliation, not vendored SDK code. Authentication, request headers, model
routing, and integration lifecycle are also owned by the provider plugin.
When updating the upstream-shaped modules, compare against both source packages
and reapply the documented Copilot adaptations. Focused regression coverage is
in:
- `test/github-copilot/copilot-chat-model.test.ts`
- `test/github-copilot/convert-to-copilot-messages.test.ts`
- `test/github-copilot/openai-responses-language-model.test.ts`
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
- `test/github-copilot/models.test.ts`
- `test/plugin/provider-github-copilot.test.ts`
@@ -653,7 +653,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
}
if (isActiveText) {
controller.enqueue({ type: "text-end", id: "txt-0" })
controller.enqueue({
type: "text-end",
id: "txt-0",
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
}
// go through all tool calls and send the ones that are not finished
+2 -7
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem.js"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
@@ -33,12 +33,7 @@ export const make = Effect.gen(function* () {
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
limits: Readonly<Limits>,
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
+1
View File
@@ -37,6 +37,7 @@ export function buildLocationServiceMap(
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
}),
),
+7 -1
View File
@@ -26,6 +26,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
@@ -156,6 +157,7 @@ export interface Connection {
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
@@ -396,7 +398,11 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({
try: (signal) =>
client.callTool(
{ name: input.name, arguments: input.args ?? {} },
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
},
CallToolResultSchema,
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
+3 -1
View File
@@ -3,6 +3,7 @@ export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import type { Session } from "@opencode-ai/schema/session"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
@@ -153,6 +154,7 @@ export interface Interface extends State.Transformable<Draft> {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
@@ -762,7 +764,7 @@ export const layer = (options?: Options) =>
message: "MCP server is not connected",
})
const result = yield* target.entry.client
.callTool({ name: input.name, args: input.args })
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
.pipe(
Effect.mapError(
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+5 -30
View File
@@ -4,7 +4,7 @@ import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Pty } from "@opencode-ai/schema/pty"
@@ -26,19 +26,9 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = Pty.Info & {
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Info = PersistentPty.Info
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Snapshot = PersistentPty.Snapshot
export type Attachment = {
readonly info: Info
@@ -161,15 +151,7 @@ export const configured = (options: Options = {}) =>
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
input: Parameters<Interface["create"]>[1],
) {
const response = yield* request(
daemon,
@@ -338,14 +320,7 @@ export const configured = (options: Options = {}) =>
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
input: Parameters<Interface["attach"]>[1],
) {
yield* get(id)
const attachment = yield* daemon
+3 -1
View File
@@ -84,7 +84,9 @@ export const Plugin = define({
})
function append(template: string, input: string) {
return [template, input.trim()].filter(Boolean).join("\n\n")
const value = input.trim()
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
return [template, value].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
+1 -1
View File
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
}
}
})
+1 -1
View File
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
+6 -4
View File
@@ -76,11 +76,13 @@ to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
When OpenCode starts, it searches from the current directory up to the project
root. It merges direct `opencode.json(c)` files from root to current directory,
During ordinary project discovery, OpenCode searches the current Location
directory and every ancestor through the filesystem root, including directories
above the detected project or repository root. It merges direct
`opencode.json(c)` files from the farthest ancestor to the current directory,
then does the same for `.opencode/opencode.json(c)` files. This means every
`.opencode` config overrides every direct config. Global configuration has the
lowest precedence.
discovered `.opencode` config overrides every discovered direct config. Global
filesystem configuration has lower precedence than these discovered documents.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
+16
View File
@@ -78,6 +78,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
}
})
@@ -89,6 +92,7 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
@@ -113,6 +117,18 @@ export const layer = Layer.effect(
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
+5 -9
View File
@@ -134,10 +134,9 @@ const layer = Layer.effect(
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
if (truncated) return rows.slice(0, input.limit)
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
@@ -147,7 +146,7 @@ const layer = Layer.effect(
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
return code === 1 ? [] : rows
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
@@ -178,7 +177,7 @@ const layer = Layer.effect(
parse: (line) => Effect.succeed(normalizePath(line)),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
result.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
@@ -212,10 +211,7 @@ const layer = Layer.effect(
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
grep: (input) =>
run<RawMatchData>({
...input,
@@ -248,7 +244,7 @@ const layer = Layer.effect(
),
}).pipe(
Effect.map((result) =>
result.items.map((match) =>
result.map((match) =>
Match.make({
entry: Entry.make({
path: RelativePath.make(match.path.text),
+20 -19
View File
@@ -393,26 +393,27 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
return yield* input.resolveModel(input.session).pipe(
Effect.matchEffect({
onFailure: (cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (resolved) =>
execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
}),
}),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
@@ -131,11 +131,7 @@ const layer = Layer.effect(
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("InstructionEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
if (actualBytes > MaxValueBytes)
yield* new ValueTooLargeError({
@@ -159,10 +155,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
})
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
yield* db
.update(InstructionEntryTable)
.set({ value: null, removed: true, time_updated: Date.now() })
+1 -4
View File
@@ -43,10 +43,7 @@ const layer = Layer.effect(
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
+2 -7
View File
@@ -15,7 +15,7 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Continuation } from "./index.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform.js"
@@ -44,12 +44,7 @@ const layer = Layer.effect(
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
const sessionID = input.sessionID
let force = input.force
let continuing = input.continuation !== undefined
+1 -1
View File
@@ -287,7 +287,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
batches(ids.map((row) => row.id)),
(batch) =>
db
.select({ created: EventTable.created, data: EventTable.data })
.select({ data: EventTable.data })
.from(EventTable)
.where(
and(
+49 -49
View File
@@ -122,8 +122,8 @@ const layer = () =>
const environments = yield* SessionEnvironment.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const commands = new Map<Shell.ID, Active>()
const exitOrder: Shell.ID[] = []
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
@@ -132,44 +132,44 @@ const layer = () =>
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
for (const command of commands.values()) {
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(session.done)
yield* Deferred.interrupt(command.done)
}
sessions.clear()
commands.clear()
exitOrder.length = 0
}),
)
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
const command = commands.get(id)
if (!command) return yield* new NotFoundError({ id })
return command
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
const command = commands.get(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (!session) return
sessions.delete(id)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
if (!command) return
commands.delete(id)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* Deferred.fail(command.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
yield* removeCommand(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
return Array.from(commands.values())
.filter((command) => command.info.status === "running")
.map((command) => command.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
@@ -181,24 +181,24 @@ const layer = () =>
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
const command = yield* require(id)
if (command.info.status !== "running" || !command.timeout) return command.info
yield* command.timeout(duration)
return command.info
})
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const command = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const length = Math.min(limit, command.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
const stream = createReadStream(command.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
@@ -212,7 +212,7 @@ const layer = () =>
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
size: command.size,
truncated: false,
}
})
@@ -257,7 +257,7 @@ const layer = () =>
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
// end). `create` returns once `ready` resolves with the registered command.
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
runFork(
Effect.scoped(
@@ -275,7 +275,7 @@ const layer = () =>
.pipe(
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
)
const session: Active = {
const command: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
@@ -283,7 +283,7 @@ const layer = () =>
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
commands.set(id, command)
const stream = createWriteStream(file)
const outputDone = Latch.makeUnsafe()
@@ -291,7 +291,7 @@ const layer = () =>
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
command.size += chunk.length
}),
),
)
@@ -317,8 +317,8 @@ const layer = () =>
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
if (command.info.status !== "running") return
command.info = produce(command.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
@@ -326,10 +326,10 @@ const layer = () =>
yield* beforeWait
yield* outputDone.await
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// command still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* Deferred.succeed(command.done, command.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
@@ -339,19 +339,19 @@ const layer = () =>
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
yield* removeCommand(oldest)
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
})
session.timeout = (duration) =>
command.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
command.timeoutFiber = undefined
if (duration === 0 || command.info.status !== "running") return
command.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
@@ -360,7 +360,7 @@ const layer = () =>
)
})
yield* session.timeout(invocation.timeout)
yield* command.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
@@ -370,16 +370,16 @@ const layer = () =>
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
yield* Deferred.succeed(ready, command)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
)
const session = yield* Deferred.await(ready)
return session.info
const command = yield* Deferred.await(ready)
return command.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
+1
View File
@@ -72,6 +72,7 @@ export const layer = Layer.effect(
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
sessionID: context.sessionID,
})
.pipe(
Effect.catchTags({
+120 -3
View File
@@ -375,7 +375,100 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
it.effect("normalizes file data across AI SDK prompt parts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("opaque-provider"))
const bytes = new Uint8Array([0, 1, 2, 3])
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "media", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{
type: "media",
mediaType: "image/png",
data: "data:image/png;charset=utf-8;base64,AQID",
filename: "inline.png",
},
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
{ type: "media", mediaType: "image/png", data: "s3://bucket/image.png" },
]),
Message.assistant({
type: "media",
mediaType: "application/pdf",
data: "http://example.com/document.pdf",
filename: "document.pdf",
}),
Message.tool({
id: "call_1",
name: "screenshot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,BAUG", mime: "image/png", name: "tool.png" }],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "user",
content: [
{ type: "file", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "file", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{ type: "file", mediaType: "image/png", data: "AQID", filename: "inline.png" },
{
type: "file",
mediaType: "image/png",
data: new URL("https://example.com/image.png"),
filename: undefined,
},
{ type: "file", mediaType: "image/png", data: "s3://bucket/image.png", filename: undefined },
],
},
{
role: "assistant",
content: [
{
type: "file",
mediaType: "application/pdf",
data: new URL("http://example.com/document.pdf"),
filename: "document.pdf",
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "screenshot",
output: { type: "text", value: "Media attached in the following user message." },
providerOptions: undefined,
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "BAUG", filename: "tool.png" },
],
},
])
}),
)
it.effect("normalizes user and tool media through the real Mistral provider", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: { messages?: unknown[] } | undefined
@@ -415,7 +508,14 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
LLM.request({
model: resolved,
messages: [
Message.user("Inspect the screenshot."),
Message.user([
{ type: "text", text: "Inspect the attachments." },
{ type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) },
{ type: "media", mediaType: "image/png", data: "AQID" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" },
{ type: "media", mediaType: "image/png", data: "http://example.com/image.png" },
{ type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
]),
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
Message.tool({
type: "tool-result",
@@ -426,6 +526,12 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
value: [
{ type: "text", text: "Screenshot captured" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
{
type: "file",
uri: "https://example.com/tool-document.pdf",
mime: "application/pdf",
name: "tool-document.pdf",
},
],
},
}),
@@ -434,7 +540,17 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
).pipe(Effect.provide(client))
expect(body?.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
{
role: "user",
content: [
{ type: "text", text: "Inspect the attachments." },
{ type: "image_url", image_url: "data:image/png;base64,AAECAw==" },
{ type: "image_url", image_url: "data:image/png;base64,AQID" },
{ type: "image_url", image_url: "data:image/png;base64,BAUG" },
{ type: "image_url", image_url: "http://example.com/image.png" },
{ type: "document_url", document_url: "https://example.com/document.pdf" },
],
},
{
role: "assistant",
content: "",
@@ -457,6 +573,7 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
{ type: "document_url", document_url: "https://example.com/tool-document.pdf" },
],
},
])
+22 -24
View File
@@ -85,8 +85,8 @@ describe("Bus Session routing", () => {
projectID: Project.ID.global,
})
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
expect(yield* Fiber.join(first)).toEqual([moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, same, done])
expect(moved.location).toEqual(a)
}),
)
@@ -130,8 +130,8 @@ describe("Bus Session routing", () => {
)
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(yield* Fiber.join(second)).toEqual([done])
}),
)
}),
@@ -158,19 +158,17 @@ describe("Bus Session routing", () => {
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
expect(yield* Fiber.join(first)).toEqual([renamed, text, broadcast, done])
expect(yield* Fiber.join(second)).toEqual([broadcast, explicit, done])
expect(yield* Fiber.join(workspace)).toEqual([broadcast, done])
expect(yield* Fiber.join(global)).toEqual([renamed, text, broadcast, explicit, done])
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
expect(renamed).not.toHaveProperty("location")
expect(text).not.toHaveProperty("location")
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
expect(
Array.from(history)
.filter((event): event is Event.Payload => !Bus.isSynced(event))
.every((event) => !event.location),
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
).toBe(true)
}),
)
@@ -197,8 +195,8 @@ describe("Bus Session routing", () => {
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
expect(yield* Fiber.join(typed)).toEqual([expected])
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
}),
)
@@ -226,8 +224,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
expect(moved).not.toHaveProperty("location")
}),
)
@@ -245,9 +243,9 @@ describe("Bus Session routing", () => {
const database = yield* Database.Service
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
expect(yield* Fiber.join(first)).toEqual([deleted, done])
expect(yield* Fiber.join(second)).toEqual([done])
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
}),
)
@@ -266,8 +264,8 @@ describe("Bus Session routing", () => {
])
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
}),
)
@@ -295,8 +293,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
expect(Exit.isFailure(single)).toBe(true)
expect(Exit.isFailure(batch)).toBe(true)
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(yield* Fiber.join(first)).toEqual([before, after, done])
expect(yield* Fiber.join(second)).toEqual([done])
}),
)
@@ -327,8 +325,8 @@ describe("Bus Session routing", () => {
{ publish: true },
)
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
const received = Array.from(yield* Fiber.join(second))
expect(yield* Fiber.join(first)).toEqual([done])
const received = yield* Fiber.join(second)
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
expect(received[1]).not.toHaveProperty("location")
}),
+15 -15
View File
@@ -123,7 +123,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "hello" })
yield* bus.publish(CountMessage, { count: 2 })
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
const received = (yield* Fiber.join(fiber)).map((event) =>
event.type === "test.message" ? event.data.text : event.data.count,
)
expect(received).toEqual(["hello", 2])
@@ -136,7 +136,7 @@ describe("Bus", () => {
const fiber = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
const received = Array.from(yield* Fiber.join(fiber))
const received = yield* Fiber.join(fiber)
expect(received).toEqual([event])
expect(event.type).toBe("test.message")
@@ -212,8 +212,8 @@ describe("Bus", () => {
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
expect(yield* Fiber.join(typed)).toEqual([event])
expect(yield* Fiber.join(wildcard)).toEqual([event])
}),
)
@@ -602,7 +602,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[1, durableData(aggregateID, "one")],
[2, durableData(aggregateID, "two")],
])
@@ -618,7 +618,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "zero")],
[1, durableData(aggregateID, "one")],
])
@@ -653,7 +653,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
yield* Deferred.succeed(continueRead, undefined)
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(eventLayer))
@@ -672,7 +672,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
}
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
)
}),
@@ -688,7 +688,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "live only" })
yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
expect((yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
}),
)
@@ -1268,7 +1268,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1284,9 +1284,9 @@ describe("Bus", () => {
const bus = yield* Bus.Service
const aggregateID = Session.ID.create()
const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const empty = yield* Stream.runCollect(bus.log({ aggregateID }))
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
const drained = yield* Stream.runCollect(bus.log({ aggregateID, after: 0 }))
expect(empty).toEqual([{ type: "log.synced", aggregateID }])
expect(empty[0]).not.toHaveProperty("seq")
@@ -1306,7 +1306,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = Array.from(yield* Fiber.join(fiber))
const items = yield* Fiber.join(fiber)
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
@@ -1330,7 +1330,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1378,7 +1378,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
yield* Deferred.succeed(releaseRead, undefined)
const items = Array.from(yield* Fiber.join(fiber))
const items = yield* Fiber.join(fiber)
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
+6 -3
View File
@@ -84,7 +84,8 @@ describe("Catalog", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
@@ -92,7 +93,8 @@ describe("Catalog", () => {
)
expect(yield* catalog.provider.available()).toEqual([])
yield* (yield* Credential.Service).create({
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "secret" }),
})
@@ -112,7 +114,8 @@ describe("Catalog", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
+4 -16
View File
@@ -330,10 +330,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
)
it.live("loads legacy file-based agents from config directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
@@ -407,10 +404,7 @@ Use native v2 fields.`,
for (const testCase of sourceCases()) {
it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, testCase.source)
@@ -445,10 +439,7 @@ Use native v2 fields.`,
}
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "agents")
@@ -485,10 +476,7 @@ Use native v2 fields.`,
)
it.effect("ignores updates outside agent source directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "agents")
+4 -16
View File
@@ -54,10 +54,7 @@ const decode = Schema.decodeUnknownSync(Info)
describe("ConfigCommandPlugin.Plugin", () => {
it.live("loads inline and file-based commands in config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
@@ -166,10 +163,7 @@ Review files`,
for (const testCase of sourceCases()) {
it.effect(`rebuilds commands when a source file is ${testCase.name}`, () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
@@ -212,10 +206,7 @@ Review files`,
}
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
@@ -254,10 +245,7 @@ Review files`,
)
it.effect("ignores updates outside command source directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
+255 -335
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
import { describe, expect, test } from "bun:test"
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
@@ -77,10 +77,7 @@ const provider = {
describe("Config", () => {
it.live("excludes home-level claude and agents directories when global is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(global, "home")
@@ -120,10 +117,7 @@ describe("Config", () => {
)
it.live("excludes global config reached through the project walk when global is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
// The location sits BENEATH the global config dir, so the upward walk
// reaches the global opencode.json as a direct file.
@@ -156,10 +150,7 @@ describe("Config", () => {
)
it.live("loads explicit file and content overrides in priority order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
@@ -194,10 +185,7 @@ describe("Config", () => {
)
it.live("skips project configuration when project discovery is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
@@ -225,10 +213,7 @@ describe("Config", () => {
)
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -261,10 +246,7 @@ describe("Config", () => {
)
it.live("exposes filesystem updates under config roots through changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -296,10 +278,7 @@ describe("Config", () => {
// watch being torn down, making recreation invisible) only reproduces with
// path-faithful event delivery.
it.live("keeps watching a deleted config file so recreating it reloads", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -369,26 +348,24 @@ describe("Config", () => {
}).pipe(Effect.provide(Config.testLayer())),
)
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
test("returns the latest defined scalar from priority-ordered documents", () => {
const entries = [
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
expect(Config.latest(entries, "default_agent")).toBeUndefined()
}),
)
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
expect(Config.latest(entries, "default_agent")).toBeUndefined()
})
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
Effect.acquireUseRelease(
@@ -580,268 +557,241 @@ describe("Config", () => {
).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
}),
)
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
}, 30_000)
it.effect("migrates the v1 experimental subagent depth", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
}),
)
test("migrates the v1 experimental subagent depth", () => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
})
it.effect("migrates the v1 small model to the title agent", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
}),
)
test("migrates the v1 small model to the title agent", () => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
})
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
}),
)
test("migrates v1 provider lists to policies", () => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
})
it.effect("migrates v1 provider setup options into AISDK settings", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
region: "us-east-1",
profile: "dev",
},
test("migrates v1 provider setup options into AISDK settings", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
region: "us-east-1",
profile: "dev",
},
},
})
},
})
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
})
}),
)
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
})
})
it.effect("renames old provider IDs while migrating v1 configuration", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
test("renames old provider IDs while migrating v1 configuration", () => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
},
command: {
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
},
},
})
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
})
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
})
test("ignores old provider IDs when the current provider ID is configured", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
})
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
})
test("migrates v1 interleaved fields to compatibility", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
})
test("migrates v1 command configuration", () => {
expect(
ConfigMigrateV1.migrate({
command: {
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
},
})
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
})
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
test("normalizes renamed permission actions when migrating v1 permissions", () => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
}),
)
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
}),
)
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
}),
)
it.effect("migrates v1 interleaved fields to compatibility", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
}),
)
it.effect("migrates v1 command configuration", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
command: {
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
},
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
}),
)
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
}),
)
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
})
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
@@ -856,10 +806,7 @@ describe("Config", () => {
)
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -888,10 +835,7 @@ describe("Config", () => {
)
it.live("does not watch ecosystem config roots", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -919,10 +863,7 @@ describe("Config", () => {
)
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1033,10 +974,7 @@ describe("Config", () => {
)
it.live("does not load legacy config.json files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1055,10 +993,7 @@ describe("Config", () => {
)
it.live("accepts $schema metadata without writing it into config files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "opencode.json")
@@ -1082,10 +1017,7 @@ describe("Config", () => {
)
it.live("loads supported scalar and resource configuration", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1271,10 +1203,7 @@ describe("Config", () => {
)
it.live("migrates the deprecated reference key into references", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1307,10 +1236,7 @@ describe("Config", () => {
)
it.live("migrates v1 configuration when a v1-only key is present", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1482,10 +1408,7 @@ describe("Config", () => {
)
it.live("ignores an invalid file while loading valid config values", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1511,10 +1434,7 @@ describe("Config", () => {
)
it.live("loads global and ancestor configuration across the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const root = path.join(tmp.path, "repo")
+91 -11
View File
@@ -2,13 +2,15 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
@@ -18,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -35,6 +37,40 @@ const staticIt = testEffect(
[Global.node, tempGlobalLayer],
]),
)
const refreshNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
return Npm.Service.of({
add: (_pkg, options) =>
options?.refresh
? Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
return installed
})
: Effect.succeed(installed),
resolve: () => Effect.succeed(installed),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[
[Global.node, tempGlobalLayer],
[Npm.node, refreshNpm],
],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
@@ -51,7 +87,6 @@ describe("PluginSupervisor config", () => {
}),
),
)
it.live("allows the built-in Plan agent to be disabled", () =>
withLocation(
{ agents: { plan: { disabled: true } } },
@@ -270,7 +305,7 @@ describe("PluginSupervisor config", () => {
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
@@ -380,7 +415,7 @@ describe("PluginSupervisor config", () => {
it.live("loads user plugins before internal post plugins", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
yield* withLocation(
{
plugins: [
@@ -431,8 +466,8 @@ describe("PluginSupervisor config", () => {
it.live("unblocks flush when plugin activation fails", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* withLocation(
undefined,
Effect.gen(function* () {
@@ -441,6 +476,47 @@ describe("PluginSupervisor config", () => {
)
}),
)
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const activated = path.join(directory, "activated")
const release = path.join(directory, "release")
const refreshed = path.join(directory, "refresh-requested")
const refreshRelease = path.join(directory, "refresh-release")
const refreshFinished = path.join(directory, "refresh-finished")
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(
path.join(directory, "index.js"),
`export default {
id: "background-refresh-plugin",
async setup() {
await Bun.write(${JSON.stringify(activated)}, "")
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
},
}`,
)
})
yield* withLocation(
{ plugins: ["background-refresh-plugin"] },
Effect.gen(function* () {
yield* waitForFile(activated)
yield* Effect.sleep("100 millis")
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
yield* Effect.promise(() => Bun.write(release, ""))
yield* waitForFile(refreshed)
yield* ready().pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
yield* waitForFile(refreshFinished)
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
}),
)
}),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -448,16 +524,20 @@ const ready = Effect.fnUntraced(function* () {
yield* supervisor.flush
})
const waitForFile = (file: string) =>
Effect.promise(() => Bun.file(file).exists()).pipe(
Effect.filterOrFail((exists) => exists),
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
Effect.timeout("2 seconds"),
)
function withLocation<A, E, R>(
config: unknown,
effect: Effect.Effect<A, E, R>,
fixtures = false,
prepare?: (directory: string) => Promise<void>,
) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.tap((tmp) =>
Effect.promise(async () => {
await prepare?.(tmp.path)
+43
View File
@@ -34,6 +34,41 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* references.transform((draft) =>
draft.add(
"external",
Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/references/external") }),
),
)
yield* ConfigReferencePlugin.Plugin.effect(host)
const result = yield* references.list()
expect(result.map((reference) => reference.name)).toEqual(["external", "shared", "first", "second"])
expect(result.find((reference) => reference.name === "shared")?.path).toBe(
AbsolutePath.make(path.resolve("/config/second/shared")),
)
}).pipe(
Effect.provide(
Config.testLayer([
referenceConfig("/config/first/opencode.json", {
shared: "./shared",
first: "./first",
}),
referenceConfig("/config/second/opencode.json", {
shared: "./shared",
second: "./second",
}),
]),
),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
),
)
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -102,6 +137,14 @@ function config(name: string) {
})
}
function referenceConfig(file: string, references: Record<string, string>) {
return new Document({
type: "document",
path: AbsolutePath.make(file),
info: decode({ references }),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -15,6 +15,14 @@ const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
const teams = sqliteTable("teams", {
id: integer().primaryKey(),
name: text().notNull(),
})
const memberships = sqliteTable("memberships", {
user_id: integer().notNull(),
team_id: integer().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -163,3 +171,41 @@ test("supports returning and rejects empty update sets", async () => {
}),
)
})
test("supports function-valued update joins with runtime table columns", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Grace" })
.from(teams)
.innerJoin(memberships, (update) => eq(update.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Grace", "Core"],
})
}),
)
})
test("supports SQL-valued update joins", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Lin" })
.from(teams)
.innerJoin(memberships, eq(users.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Lin", "Core"],
})
}),
)
})
@@ -16,18 +16,7 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
}
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
return Stream.runCollect(stream).pipe(
Effect.map((chunks) => {
const total = chunks.reduce((acc, x) => acc + x.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const chunk of chunks) {
out.set(chunk, off)
off += chunk.length
}
return new TextDecoder("utf-8").decode(out).trim()
}),
)
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
}
function alive(pid: number) {
@@ -201,7 +201,8 @@ describe("layer node", () => {
Layer.provide(LayerNode.compile(result.hoisted)),
) as unknown as Layer.Layer<App>
const program = Effect.gen(function* () {
return yield* (yield* App).run
const app = yield* App
return yield* app.run
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["Alice"])
+140 -141
View File
@@ -1,6 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test"
import { describe, expect, spyOn } from "bun:test"
import fuzzysort from "fuzzysort"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { mkdir } from "node:fs/promises"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
@@ -14,6 +14,8 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
Layer.succeed(
@@ -32,14 +34,13 @@ const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void)
)
describe("FileSystemSearch", () => {
test("honors wildcard directory rules from .gitignore", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
try {
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
expect(git.exitCode).toBe(0)
it.live("honors wildcard directory rules from .gitignore", () =>
Effect.gen(function* () {
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-fff-ignore-")))).path
yield* Effect.promise(() => mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(directory, ".gitignore"), "**/target/\n"))
yield* Effect.promise(() => Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored"))
expect(Bun.spawnSync(["git", "init", "-q"], { cwd: directory }).exitCode).toBe(0)
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const layer = FileSystemSearch.fffLayer.pipe(
@@ -54,26 +55,22 @@ describe("FileSystemSearch", () => {
),
),
)
const entries = await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
return yield* search.find({ query: "target" })
}).pipe(Effect.provide(layer), Effect.scoped),
)
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "target" })
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
}).pipe(Effect.provide(layer))
}),
)
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
try {
it.live("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", () =>
Effect.gen(function* () {
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-search-workspace-"))))
.path
// A local file that only an fff index of the server directory could surface.
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
// true; elsewhere the layer choice already falls back to ripgrep.
await Bun.write(path.join(directory, "server-local.ts"), "server local")
yield* Effect.promise(() => Bun.write(path.join(directory, "server-local.ts"), "server local"))
let observed: Ripgrep.FindInput | undefined
const ref = Location.Ref.make({
directory: AbsolutePath.make(directory),
@@ -92,37 +89,35 @@ describe("FileSystemSearch", () => {
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer), Effect.scoped),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer))
}),
)
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
it.live("bounds a home scan even when home is detected as a repository", () =>
Effect.gen(function* () {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: home },
{ vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } },
),
),
),
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Effect.sleep("10 millis")
expect(observed).toBeUndefined()
@@ -132,52 +127,52 @@ describe("FileSystemSearch", () => {
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
RelativePath.make(`src${path.sep}`),
)
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
}).pipe(Effect.provide(layer))
}),
)
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
it.effect("refreshes a stale ripgrep index atomically without blocking search", () =>
Effect.gen(function* () {
let scans = 0
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "old", type: "file" })
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
@@ -196,47 +191,53 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
expect(scans).toBe(2)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
})
}).pipe(Effect.provide(layer))
}),
)
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
it.effect("reuses location-owned fuzzy targets across index refreshes", () =>
Effect.gen(function* () {
let scans = 0
const second = yield* Deferred.make<void>()
const prepare = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(fuzzysort, "prepare")),
(value) => Effect.sync(() => value.mockRestore()),
)
const cleanup = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(fuzzysort, "cleanup")),
(value) => Effect.sync(() => value.mockRestore()),
)
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
@@ -246,9 +247,7 @@ describe("FileSystemSearch", () => {
expect(prepare).toHaveBeenCalledTimes(2)
expect(cleanup).toHaveBeenCalledTimes(3)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
prepare.mockRestore()
cleanup.mockRestore()
})
}).pipe(Effect.provide(layer))
}),
)
})
@@ -244,14 +244,25 @@ describe("doStream", () => {
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
// In this fixture, reasoning_opaque comes AFTER content has started (in chunk 4)
// So it arrives too late to be attached to reasoning-end. But it should still
// be captured and included in the finish event's providerMetadata.
// So it arrives too late to be attached to reasoning-end. It should still be
// captured on the completed text part and the finish event.
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
})
const textEnd = parts.find((p) => p.type === "text-end")
expect(textEnd).toEqual({
type: "text-end",
id: "txt-0",
providerMetadata: {
copilot: {
reasoningOpaque: "/PMlTqxqSJZnUBDHgnnJKLVI4eZQ",
},
},
})
// reasoning_opaque should be in the finish event's providerMetadata
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
@@ -305,6 +316,17 @@ describe("doStream", () => {
},
})
const textEnd = parts.find((p) => p.type === "text-end")
expect(textEnd).toEqual({
type: "text-end",
id: "txt-0",
providerMetadata: {
copilot: {
reasoningOpaque: "ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx",
},
},
})
// Check text deltas
const textDeltas = parts.filter((p) => p.type === "text-delta")
expect(textDeltas).toHaveLength(2)
+1 -1
View File
@@ -186,7 +186,7 @@ describe("Integration", () => {
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
}),
])
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
expect((yield* Fiber.join(created)).map((event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
])
@@ -49,6 +49,29 @@ export const environmentConformance = <E>(
}),
)
check("observes filesystem state when an operation executes", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/deferred.txt`
const source = `${harness.root}/source.txt`
const destination = `${harness.root}/destination.txt`
const read = harness.files.read(target)
const stat = harness.files.stat(target)
const list = harness.files.list(harness.root)
const move = harness.files.move(source, destination)
yield* harness.files.write(target, bytes("first"))
yield* harness.files.write(source, bytes("moved"))
expect(text((yield* read).bytes)).toBe("first")
expect((yield* stat).size).toBe(5)
expect(yield* list).toContainEqual({ name: "deferred.txt", type: "file" })
yield* move
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
yield* harness.files.write(target, bytes("second"))
expect(text((yield* read).bytes)).toBe("second")
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
@@ -51,7 +51,8 @@ describe("FileSystem", () => {
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const entries = yield* (yield* FileSystem.Service).list()
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list()
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make("src" + path.sep), type: "directory" },
{ path: RelativePath.make("README.md"), type: "file" },
@@ -103,9 +104,8 @@ describe("FileSystem", () => {
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const result = yield* (yield* FileSystem.Service)
.read({ path: RelativePath.make("../outside.txt") })
.pipe(Effect.exit)
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(directory)),
),
+12 -2
View File
@@ -13,6 +13,7 @@ import {
Hash,
Layer,
LayerMap,
Option,
RcMap,
Schema,
Stream,
@@ -517,7 +518,8 @@ describe("LocationServiceMap", () => {
)
const plugins = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* (yield* PluginSupervisor.Service).flush
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
return yield* plugins.list()
}).pipe(
Effect.scoped,
@@ -674,14 +676,21 @@ describe("LocationServiceMap", () => {
expect(Equal.equals(absent, present)).toBe(false)
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first)
expect(Option.getOrThrow(yield* locations.contextEffectOption(absent))).toBe(first)
expect(Option.getOrThrow(yield* locations.contextEffectOption(present))).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
expect(yield* locations.contextEffectOption(present)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
}),
),
@@ -934,7 +943,8 @@ describe("LocationServiceMap", () => {
})
yield* plugins.activate([{ ...reviewer, version: "1" }])
expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",
mode: "subagent",
})
+24 -12
View File
@@ -43,7 +43,8 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -58,7 +59,8 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -70,7 +72,8 @@ describe("LocationMutation", () => {
it.live("requires external-directory authorization for a relative lexical escape", () =>
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -117,7 +120,8 @@ describe("LocationMutation", () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
@@ -137,7 +141,8 @@ describe("LocationMutation", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
const mutation = yield* LocationMutation.Service
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -149,7 +154,8 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -164,7 +170,8 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -185,7 +192,8 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)),
@@ -197,7 +205,8 @@ describe("LocationMutation", () => {
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -212,7 +221,8 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -254,7 +264,8 @@ describe("LocationMutation", () => {
it.live("resolves a tilde path as an external home target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
@@ -270,7 +281,8 @@ describe("LocationMutation", () => {
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
+104
View File
@@ -49,6 +49,7 @@ import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/t
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
let calls = 0
let invocations: Array<Parameters<Mcp.Interface["callTool"]>[0]> = []
type ResourcePage = {
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
@@ -83,6 +84,12 @@ function resourceServer(
resourceLists: 0,
templateLists: 0,
toolLists: 0,
toolCalls: [] as Array<{
name: string
arguments: Record<string, unknown> | undefined
sessionID: unknown
progressToken: unknown
}>,
initializations: 0,
urls: [] as string[],
}
@@ -132,6 +139,17 @@ function resourceServer(
}
})
}
if (!input.emptyElicitation && !input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, (request) => {
state.toolCalls.push({
name: request.params.name,
arguments: request.params.arguments,
sessionID: request.params._meta?.sessionID,
progressToken: request.params._meta?.progressToken,
})
return Promise.resolve({ content: [] })
})
}
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
@@ -313,6 +331,7 @@ const mcp = Layer.mock(Mcp.Service, {
callTool: (input) =>
Effect.sync(() => {
calls += 1
invocations.push(input)
if (input.name === "fail")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
@@ -380,6 +399,43 @@ test("MCP tool names match V1 sanitization", () => {
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
test("passes session IDs as MCP request metadata", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer()
const connection = yield* connect(
"session-metadata",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
yield* connection.callTool({
name: "echo",
args: { text: "hello" },
sessionID: Session.ID.make("ses_mcp_metadata"),
})
yield* connection.callTool({ name: "echo" })
expect(server.state.toolCalls).toEqual([
{
name: "echo",
arguments: { text: "hello" },
sessionID: "ses_mcp_metadata",
progressToken: expect.any(Number),
},
{
name: "echo",
arguments: {},
sessionID: undefined,
progressToken: expect.any(Number),
},
])
expect(server.state.toolCalls[0]?.progressToken).not.toBe(server.state.toolCalls[1]?.progressToken)
}),
),
)
})
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
@@ -1610,6 +1666,54 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("forwards the invoking session through direct and Code Mode MCP tools", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
invocations = []
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
"properties.sessionID",
)
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
const directSessionID = Session.ID.make("ses_mcp_direct")
yield* toolSet.execute({
sessionID: directSessionID,
...toolIdentity,
call: { type: "tool-call", id: "call_mcp_direct", name: "direct_lookup", input: {} },
})
expect(invocations[0]).toEqual({
server: "direct",
name: "lookup",
args: {},
sessionID: directSessionID,
})
const codeModeSessionID = Session.ID.make("ses_mcp_codemode")
yield* toolSet.execute({
sessionID: codeModeSessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_codemode",
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
})
expect(invocations[1]).toEqual({
server: "demo",
name: "search",
args: {},
sessionID: codeModeSessionID,
})
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
+1 -1
View File
@@ -234,7 +234,7 @@ describe("Npm.add", () => {
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const mutableEntry = yield* npm.add(mutable)
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
+48 -3
View File
@@ -16,6 +16,7 @@ import { emptyMcpLayer } from "../fixture/mcp"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
import PROMPT_INITIALIZE from "../../src/plugin/command/initialize.txt"
import PROMPT_REVIEW from "../../src/plugin/command/review.txt"
const directory = AbsolutePath.make("/repo/packages/app")
@@ -40,7 +41,11 @@ describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* Command.Service
const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = []
const prompts: {
text: string
files?: readonly { readonly uri: string }[]
delivery?: "steer" | "queue"
}[] = []
yield* CommandPlugin.Plugin.effect(
host({
command: {
@@ -51,7 +56,7 @@ describe("CommandPlugin.Plugin", () => {
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files })
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
@@ -86,10 +91,50 @@ describe("CommandPlugin.Plugin", () => {
delivery: "queue",
},
})
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: " branch $& $$ $` $' " },
delivery: "steer",
},
})
yield* command.execute({
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "" },
delivery: "steer",
},
})
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: " " },
delivery: "steer",
},
})
expect(prompts).toEqual([
{
text: expect.stringContaining("extra context"),
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
files: [{ uri: "file:///tmp/context.md" }],
delivery: "queue",
},
{
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
files: undefined,
delivery: "steer",
},
{
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
{
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
])
}),
+62 -1
View File
@@ -1,7 +1,7 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer } from "effect"
import { Effect, Exit, Layer, Scope } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -11,6 +11,8 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -18,6 +20,7 @@ import { withEnv } from "../fixture/env"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host"
import { PluginTestLayer } from "./fixture"
const locationLayer = Layer.succeed(
Location.Service,
@@ -27,10 +30,68 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
[Location.node, locationLayer],
])
const it = testEffect(layer)
const real = testEffect(PluginTestLayer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
describe("ModelsDevPlugin", () => {
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugins = yield* Plugin.Service
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("model")
const modelsDev = ModelsDev.Service.of({
get: () =>
Effect.succeed([
{
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{
id: modelID,
modelID,
providerID,
name: "Model",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 32_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]),
refresh: () => Effect.void,
})
const pluginHost = yield* PluginHost.make(plugins)
yield* ModelsDevPlugin.effect(pluginHost).pipe(Effect.provideService(ModelsDev.Service, modelsDev))
const scope = yield* Scope.make()
yield* catalog
.transform((draft) =>
draft.model.update(providerID, modelID, (model) => {
model.variants ??= []
model.variants.push({ id: Model.VariantID.make("configured") })
}),
)
.pipe(Scope.provide(scope))
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([
{ id: Model.VariantID.make("configured") },
])
yield* Scope.close(scope, Exit.void)
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
}),
)
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+29
View File
@@ -0,0 +1,29 @@
import path from "path"
import { pathToFileURL } from "url"
import { expect, test } from "bun:test"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { Npm } from "@opencode-ai/util/npm"
import { Effect } from "effect"
test("loads cached plugin packages without requesting a refresh", async () => {
const calls: unknown[] = []
const entrypoint = path.join(import.meta.dir, "fixtures", "config-effect-plugin.ts")
const plugin = await PluginModule.load({ type: "add", target: "fixture-plugin", options: {} }).pipe(
Effect.provideService(
Npm.Service,
Npm.Service.of({
add: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: () => Effect.die(new Error("Unexpected resolve")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
Effect.runPromise,
)
expect(plugin.id).toBe("config-effect-plugin")
expect(calls).toEqual([{ subpaths: ["server", ""] }])
})
+37 -31
View File
@@ -70,20 +70,12 @@ describe("AppProcess", () => {
"requireSuccess fails on non-zero exit",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(
svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)")
} else {
throw new Error("expected fail reason")
}
}
const error = yield* svc
.run(cmd("-e", "process.exit(1)"))
.pipe(Effect.flatMap(AppProcess.requireSuccess), Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
expect(error.exitCode).toBe(1)
expect(error.message).toContain("Command failed (exit 1)")
}),
)
@@ -105,15 +97,9 @@ describe("AppProcess", () => {
expect(okZero.exitCode).toBe(0)
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
expect(okOne.exitCode).toBe(1)
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
}
}
const error = yield* svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne), Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
expect(error.exitCode).toBe(2)
}),
)
@@ -313,18 +299,38 @@ describe("AppProcess", () => {
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect)
expect(Array.from(allowed)).toEqual(["only"])
const error = yield* svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect, Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
}),
)
it.live(
"includes stderr in output while retaining capped failure diagnostics",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const lines: string[] = []
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect),
.runStream(
cmd(
"-e",
"console.log('stdout-line'); console.error('stderr-line'); console.error('diagnostic-tail'); process.exit(2)",
),
{ includeStderr: true, maxErrorBytes: 20, okExitCodes: [0] },
)
.pipe(Stream.runForEach((line) => Effect.sync(() => lines.push(line)))),
)
expect(lines.toSorted()).toEqual(["diagnostic-tail", "stderr-line", "stdout-line"])
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
}
}
if (!Exit.isFailure(exit)) return
const reason = exit.cause.reasons[0]
expect(reason?._tag).toBe("Fail")
if (!reason || reason._tag !== "Fail") return
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect(reason.error.stderr).toBe("stderr-line\ndiagnost")
}),
)
+4 -2
View File
@@ -25,7 +25,8 @@ describe("RepositoryCache", () => {
await fs.writeFile(path.join(localPath, "stale.txt"), "stale")
})
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* exists(path.join(localPath, "stale.txt"))).toBe(false)
@@ -94,7 +95,8 @@ describe("RepositoryCache", () => {
Effect.gen(function* () {
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
@@ -20,6 +20,8 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -301,6 +303,54 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
}),
)
it.effect("manual compaction records model resolution failures without calling the model", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_resolution_failure")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const inputID = SessionMessage.ID.make("msg_manual_resolution_failure")
expect(
yield* compaction.compactManual({
session,
resolveModel: () =>
Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID,
}),
).toEqual({
status: "failed",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
})
expect(requests).toHaveLength(0)
expect(yield* store.context(sessionID)).toMatchObject([
{
id: inputID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
},
])
}),
)
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
Effect.gen(function* () {
requests = []
+12 -1
View File
@@ -67,6 +67,17 @@ const liveIt = testEffect(
],
),
)
const projectIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
[LocationServiceMap.node, promptLocationLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = Session.ID.create()
@@ -119,7 +130,7 @@ describe("Session.create", () => {
),
)
liveIt.live("follows the directory's project identity established after creation", () =>
projectIt.live("follows the directory's project identity established after creation", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -857,8 +857,7 @@ describe("SessionModelTransport", () => {
test("records metadata-only lifecycle metrics", async () => {
const fixture = automatic()
await run(
fixture.connector,
await Effect.runPromise(
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
@@ -874,7 +873,11 @@ describe("SessionModelTransport", () => {
)
expect(JSON.stringify(lifecycle)).not.toContain("secret-one")
expect(JSON.stringify(lifecycle)).not.toContain("secret-two")
}),
}).pipe(
Effect.provide(SessionModelTransport.makeLayer(fixture.connector)),
Effect.scoped,
Effect.provideService(Metric.MetricRegistry, new Map()),
),
)
})
})
+4 -4
View File
@@ -78,8 +78,7 @@ const locations = Layer.effect(
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
}),
Layer.succeed(
PluginSupervisor.Service,
@@ -172,8 +171,9 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
describe("Session.prompt", () => {
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
const session = yield* Session.Service
activeSessions.add(sessionID)
expect(Array.from(yield* (yield* Session.Service).active)).toEqual([sessionID])
expect(Array.from(yield* session.active)).toEqual([sessionID])
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
)
@@ -557,7 +557,7 @@ describe("Session.prompt", () => {
yield* session.resume(sessionID)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([message.id])
expect(executionCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
}),
+2 -4
View File
@@ -105,11 +105,9 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
execute: () => new ForeignFailure({ message: "transport died" }) as never,
}
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
expect(exit._tag).toBe("Failure")
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
expect(error).toBeInstanceOf(Tool.Error)
expect((error as Tool.Error).message).toBe("transport died")
expect(error.message).toBe("transport died")
})
test("execute supports callable namespace tools", async () => {
+2 -2
View File
@@ -171,7 +171,7 @@ describe("Worktree", () => {
{ directory: created.directory, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
@@ -550,7 +550,7 @@ describe("Worktree", () => {
{ directory: existing, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
+2 -2
View File
@@ -1,5 +1,5 @@
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { WebsearchApi } from "@opencode-ai/client/effect/api"
import type { WebSearchApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
@@ -9,7 +9,7 @@ export interface WebSearchDefinition {
readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
}
export interface WebSearchDomain extends WebsearchApi<unknown> {
export interface WebSearchDomain extends WebSearchApi<unknown> {
readonly transform: Transform<WebSearchDraft>
readonly reload: () => Effect.Effect<void>
}
+7 -9
View File
@@ -55,15 +55,13 @@ export const makeFormGroup = <
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
+7 -9
View File
@@ -90,15 +90,13 @@ export const makePermissionGroup = <
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", {
+16 -5
View File
@@ -1,4 +1,6 @@
import { Database } from "@opencode-ai/core/database/database"
import { Form } from "@opencode-ai/core/form"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import {
ConflictError,
FormAlreadySettledError,
@@ -6,10 +8,10 @@ import {
FormNotFoundError,
InvalidRequestError,
} from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
@@ -17,6 +19,8 @@ function missingForm(id: Form.ID) {
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const database = yield* Database.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
@@ -35,9 +39,16 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
const forms = yield* form.list({ sessionID: ctx.params.sessionID })
return { data: forms }
const ref =
ctx.params.sessionID === "global"
? requestRef(ctx.request)
: yield* sessionRef(database, ctx.params.sessionID)
const forms = yield* withLoadedLocationServices(
locations,
ref,
Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })),
)
return { data: Option.getOrElse(forms, () => []) }
}),
)
.handle(
+13 -4
View File
@@ -1,11 +1,13 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
import { response, sessionRef, withLoadedLocationServices } from "../location"
function missingRequest(id: Permission.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
@@ -13,6 +15,8 @@ function missingRequest(id: Permission.ID) {
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const database = yield* Database.Service
const requireOwnedRequest = Effect.fnUntraced(function* (
sessionID: Permission.Request["sessionID"],
requestID: Permission.ID,
@@ -63,8 +67,13 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
const permission = yield* Permission.Service
return { data: yield* permission.forSession(ctx.params.sessionID) }
const ref = yield* sessionRef(database, ctx.params.sessionID)
const requests = yield* withLoadedLocationServices(
locations,
ref,
Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID)),
)
return { data: Option.getOrElse(requests, () => []) }
}),
)
.handle(
+41 -1
View File
@@ -1,8 +1,13 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { Effect, Layer } from "effect"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -26,6 +31,41 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
})
}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export function sessionRef(database: Context.Service.Shape<typeof Database.Service>, sessionID: unknown) {
return Effect.gen(function* () {
const id = yield* decodeSessionID(sessionID).pipe(
Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
)
const row = yield* database.db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, id))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new SessionNotFoundError({ sessionID: id, message: `Session not found: ${id}` })
return Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
})
})
}
export function withLoadedLocationServices<A, E>(
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
ref: Location.Ref,
effect: Effect.Effect<A, E, LocationServices>,
) {
return Effect.scoped(
Effect.gen(function* () {
const context = yield* locations.contextEffectOption(ref)
if (Option.isNone(context)) return Option.none<A>()
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
}),
)
}
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
@@ -1,16 +1,10 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { requestRef, type LocationServices } from "../location"
import { requestRef, sessionRef, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
@@ -19,12 +13,10 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export const formLocationLayer = Layer.effect(
FormLocationMiddleware,
Effect.gen(function* () {
const { db } = yield* Database.Service
const database = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return FormLocationMiddleware.of((effect) =>
@@ -38,38 +30,8 @@ export const formLocationLayer = Layer.effect(
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) {
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
}
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
)
const ref = yield* sessionRef(database, route.params.sessionID)
return yield* effect.pipe(Effect.provide(locations.get(ref)))
}),
)
}),
@@ -1,16 +1,10 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import type { LocationServices } from "../location"
import { sessionRef, type LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
@@ -19,48 +13,17 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export const sessionLocationLayer = Layer.effect(
SessionLocationMiddleware,
Effect.gen(function* () {
const { db } = yield* Database.Service
const database = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return SessionLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
)
const ref = yield* sessionRef(database, route.params.sessionID)
return yield* effect.pipe(Effect.provide(locations.get(ref)))
}),
)
}),
+124
View File
@@ -4,6 +4,7 @@ import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -276,6 +277,129 @@ it.live("serves the session view operation and missing-session error", () =>
}),
)
it.live("does not load a location when reading pending session requests", () =>
Effect.gen(function* () {
const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-pending-read-")))
const handler = yield* ServerFetch.make({
...options,
config: {
directory: config.path,
project: false,
content: JSON.stringify({ permissions: [{ action: "shell", resource: "*", effect: "ask" }] }),
},
})
const created = (yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
).then((response) => response.json()),
)) as { data: { id: string } }
const loaded = () =>
Effect.promise(() =>
handler(new Request("http://opencode.local/api/debug/location")).then(
(response) => response.json() as Promise<unknown[]>,
),
)
expect(yield* loaded()).toEqual([])
for (const resource of ["permission", "form"]) {
const response = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`)),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
const missing = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`)),
)
expect(missing.status).toBe(404)
}
const global = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
}),
),
)
expect(global.status).toBe(200)
expect(yield* Effect.promise(() => global.json())).toEqual({ data: [] })
expect(yield* loaded()).toEqual([])
const createdForm = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/form`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Test form", fields: [{ key: "answer", type: "string" }] }),
}),
),
)
expect(createdForm.status).toBe(200)
const forms = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`)),
)
expect(forms.status).toBe(200)
expect(yield* Effect.promise(() => forms.json())).toMatchObject({
data: [{ title: "Test form" }],
})
expect(yield* loaded()).toHaveLength(1)
const globalForm = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencode-directory": encodeURIComponent(process.cwd()),
},
body: JSON.stringify({ title: "Global form", fields: [{ key: "answer", type: "string" }] }),
}),
),
)
expect(globalForm.status).toBe(200)
const globalForms = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
}),
),
)
expect(globalForms.status).toBe(200)
expect(yield* Effect.promise(() => globalForms.json())).toMatchObject({ data: [{ title: "Global form" }] })
// Agent permission policy is installed by plugin activation.
expect((yield* ready(handler)).status).toBe(200)
const createdPermission = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/permission`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: "per_pending_read", action: "shell", resources: ["pwd"] }),
}),
),
)
expect(createdPermission.status).toBe(200)
expect(yield* Effect.promise(() => createdPermission.json())).toEqual({
data: { id: "per_pending_read", effect: "ask" },
})
const permissions = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)),
)
expect(permissions.status).toBe(200)
expect(yield* Effect.promise(() => permissions.json())).toMatchObject({
data: [{ id: "per_pending_read", sessionID: created.data.id, action: "shell", resources: ["pwd"] }],
})
expect(yield* loaded()).toHaveLength(1)
}),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"bench:diff": "bun run --conditions=browser script/bench-diff.tsx",
"typecheck": "tsgo -b"
},
"exports": {
+68
View File
@@ -0,0 +1,68 @@
# Diff Rendering
## Goal
Keep complete large diffs usable without hiding patches or requiring loading consent. Measure initial rendering and scrolling independently, and preserve first/last-line access, wrapping, gutters, navigation, syntax, and selection.
## Benchmark
Run from `packages/tui`:
```sh
bun run bench:diff hunks 1000
bun run bench:diff lines 50000
DIFF_BENCH_WIDTH=80 bun run bench:diff lines 50000
bun run bench:diff long 20000
DIFF_BENCH_FILES=40 bun run bench:diff hunks 25
```
The benchmark renders the production `PatchDiff`, with one containing box per file as in the viewer. It bypasses size guards, verifies access to the complete patch's last line, warms up once, and measures seven runs by default. `DIFF_BENCH_RUNS` overrides the measured run count; `DIFF_BENCH_SYNTAX=1` enables JSON highlighting. Report first-frame median/spread, scrolling p95/max, and process RSS separately.
Do not render hunks directly under the benchmark's scrollbox: that gives them direct-child viewport culling that the viewer's file cards do not have.
## Hypotheses
1. Per-hunk gutter-sync scheduling repeats whole-file work. Coalescing callbacks should remove that quadratic mount cost without changing rendered content.
2. Gutter framebuffers and background painting scale with content height. Viewport-bounded painting should reduce giant-hunk scroll cost and native allocation without windowing text content.
3. Repeated `lineInfo` reads decode full-document arrays. Correctly invalidated caching or range reads should reduce metadata allocation while preserving wrapped/source-line mappings.
## Results
Measured on the local machine with one warmup and three measured runs:
```sh
DIFF_BENCH_RUNS=3 bun run bench:diff hunks 1000
```
| Experiment | Ready Median | Ready Range | Scroll p95 | Scroll Max | Process RSS Max |
| ---------------------------------------------- | ------------ | ------------ | ---------- | ---------- | --------------- |
| Baseline, complete file-card subtree | 8762 ms | 7845-9027 ms | 33.36 ms | 70.61 ms | 1616 MB |
| Coalesce whole-file gutter callbacks | 324 ms | 306-377 ms | 31.58 ms | 34.27 ms | 967 MB |
| Read widths without resetting number/sign maps | 307 ms | 301-318 ms | 29.61 ms | 34.86 ms | 967 MB |
Keep callback coalescing: initial rendering improves substantially with the same complete content and tail access. It does not solve steady-state painting; scrolling remains about 32 ms p95. RSS includes JavaScript/native allocation and process high-water behavior across repeated mounts, not just gutter storage.
The narrower width pass removes redundant map replacement and unbounded argument-list maxima. Its timing difference overlaps the earlier spread; keep it for fewer data mutations, not as an independently proven speedup.
Before the renderer patch, the same component with a 50,000-line modified hunk at 160 columns measured 225 ms median ready time (215-239 ms), 22.55 ms scroll p95, and 25.52 ms scroll maximum across three runs. A 100,000-line modified hunk at 80 columns measured 530 ms ready time and 60.51 ms scroll p95 in a single measured probe; that single-run probe is diagnostic, not a stable benchmark comparison.
The benchmark's initial setup is not a performance result; production-component and full-viewer Drive verification are complementary. Record subsequent isolated experiments here before keeping them.
## Final Matrix
The unguarded V2 base `0d42e76006` and the final consumer patch use the same harness and fixtures. Each case warms up once and measures seven runs; all cases run sequentially without concurrent build/test activity. Syntax is disabled in this rendering benchmark; the production viewer tests and Drive fixtures exercise JSON highlighting separately.
| Fixture | Ready Median Before / After | Scroll p95 Before / After | Process RSS Max Before / After |
| ------------------------------------- | --------------------------- | ------------------------- | ------------------------------ |
| 1,000 original hunks, 160 columns | 9083 / 264 ms | 53.11 / 7.20 ms | 1600 / 871 MB |
| 50,000 modified lines, split | 231 / 160 ms | 38.57 / 0.34 ms | 784 / 677 MB |
| 50,000 modified lines, unified | 236 / 164 ms | 21.67 / 0.29 ms | 1029 / 674 MB |
| 20,000-character modified line, split | 59 / 77 ms | 0.33 / 0.27 ms | 240 / 234 MB |
| 40 files with 25 hunks each | 343 / 161 ms | 1.48 / 1.28 ms | 790 / 568 MB |
| 100,000 modified lines, unified | 552 / 331 ms | 53.42 / 0.36 ms | 1532 / 1084 MB |
All cases expose their complete tails. Long markers can cross wrap rows, so that case reconstructs the visible final Code pane without interleaving the other side; the earlier contiguous-screen-marker check falsely rejected both revisions and was corrected identically before accepting these results.
The long-line opening adds about one frame (18 ms) to settle actual pane-width alignment. That is a measured correctness tradeoff, not a speedup. The many-hunk base also shows substantial spread (7879-17307 ms ready, 309 ms maximum scroll frame); the final ready range is 259-301 ms with a 19 ms maximum scroll frame. Measurements include process/compiler/allocator behavior and should not be presented as hard latency or memory guarantees.
The dependency patch retains complete native text/layout and selection owners. It bounds decoration raster allocation, paint visits, and source-ID readback, not source loading or full content-node virtualization. Native `0.5.9` platform binaries remain unchanged.
+175
View File
@@ -0,0 +1,175 @@
/** @jsxImportSource @opentui/solid */
import { CodeRenderable, type Renderable, type ScrollBoxRenderable } from "@opentui/core"
import { testRender, useKeyboard } from "@opentui/solid"
import { For } from "solid-js"
import { PatchDiff } from "../src/component/patch-diff"
import { ConfigProvider } from "../src/config"
import { ThemeProvider, useThemes } from "../src/context/theme"
import { TestTuiContexts } from "../test/fixture/tui-environment"
import { emptyThemeSource } from "../test/fixture/fixture"
import { largeDiffFixture } from "../test/fixture/large-diff"
import { createTuiResolvedConfig } from "../test/fixture/tui-runtime"
const kind = process.argv[2] ?? "hunks"
if (kind !== "hunks" && kind !== "lines" && kind !== "long") throw new Error("Use hunks, lines, or long")
const count = Number(process.argv[3] ?? (kind === "hunks" ? 1000 : 20000))
const width = Number(process.env.DIFF_BENCH_WIDTH ?? 160)
const files = Number(process.env.DIFF_BENCH_FILES ?? 1)
const iterations = Number(process.env.DIFF_BENCH_RUNS ?? 7)
const fixture = largeDiffFixture(kind, count)
const results: { ready: number; scroll: number[]; rss: number }[] = []
for (let iteration = 0; iteration <= iterations; iteration++) {
let scroll: ScrollBoxRenderable | undefined
function Content() {
const themes = useThemes()
const theme = themes.current
useKeyboard((key) => {
if (key.name === "j") scroll?.scrollBy(1)
})
return (
<scrollbox
ref={(node: ScrollBoxRenderable) => (scroll = node)}
width="100%"
height="100%"
verticalScrollbarOptions={{ visible: false }}
>
<For each={Array.from({ length: files }, (_, index) => index)}>
{() => (
<box>
<PatchDiff
diff={fixture.patch}
view={width >= 100 ? "split" : "unified"}
width="100%"
wrapMode="char"
filetype={process.env.DIFF_BENCH_SYNTAX ? "json" : undefined}
syntaxStyle={themes.currentSyntax()}
showLineNumbers
hunkFg={theme.diff.text.hunkHeader}
fg={theme.text.default}
addedBg={theme.diff.background.added}
removedBg={theme.diff.background.removed}
contextBg={theme.diff.background.context}
addedSignColor={theme.diff.highlight.added}
removedSignColor={theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={theme.diff.background.context}
addedLineNumberBg={theme.diff.lineNumber.background.added}
removedLineNumberBg={theme.diff.lineNumber.background.removed}
/>
</box>
)}
</For>
</scrollbox>
)
}
const started = performance.now()
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Content />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 30 },
)
try {
await app.flush()
const ready = performance.now() - started
if (!scroll) throw new Error("Missing patch viewport")
scroll.scrollTo(scroll.scrollHeight)
await app.flush()
const code = kind === "long" ? codeNodes(app.renderer.root).at(-1) : undefined
if (kind === "long" && !code) throw new Error("Missing diff code owner")
const frame = app.captureCharFrame()
// A long marker may cross wrap rows; reconstruct its pane without interleaving the other side.
const visible = code
? frame
.split("\n")
.map((line) => line.slice(code.x, code.x + code.width))
.join("")
.replace(/\s/g, "")
: frame
if (!visible.includes(fixture.tail)) throw new Error("The end of the full diff is not accessible")
scroll.scrollTo(Math.floor(scroll.scrollHeight / 2))
await app.flush()
const frames: number[] = []
for (let frame = 0; frame < 20; frame++) {
const started = performance.now()
app.mockInput.pressKey("j")
await app.renderOnce()
frames.push(performance.now() - started)
}
const result = { ready, scroll: frames, rss: process.memoryUsage().rss }
if (iteration > 0) results.push(result)
console.log(
JSON.stringify({
iteration,
warmup: iteration === 0,
ready_ms: ready,
scroll_p95_ms: percentile(frames, 0.95),
rss_bytes: result.rss,
}),
)
} finally {
app.renderer.destroy()
Bun.gc(true)
}
}
console.log(
JSON.stringify({
kind,
count,
width,
files,
iterations,
ready_median_ms: percentile(
results.map((result) => result.ready),
0.5,
),
ready_min_ms: percentile(
results.map((result) => result.ready),
0,
),
ready_max_ms: percentile(
results.map((result) => result.ready),
1,
),
scroll_p95_ms: percentile(
results.flatMap((result) => result.scroll),
0.95,
),
scroll_max_ms: percentile(
results.flatMap((result) => result.scroll),
1,
),
rss_max_bytes: percentile(
results.map((result) => result.rss),
1,
),
}),
)
console.log(
`METRIC diff_ready_ms=${percentile(
results.map((result) => result.ready),
0.5,
).toFixed(2)}`,
)
console.log(
`METRIC diff_scroll_p95_ms=${percentile(
results.flatMap((result) => result.scroll),
0.95,
).toFixed(2)}`,
)
function percentile(values: number[], fraction: number) {
return [...values].sort((left, right) => left - right)[Math.ceil((values.length - 1) * fraction)]
}
function codeNodes(root: Renderable): CodeRenderable[] {
return root instanceof CodeRenderable ? [root] : root.getChildren().flatMap(codeNodes)
}
+15 -10
View File
@@ -68,7 +68,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
import { DialogOpen, DialogOpenKey, moveOpenSession } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
import { createPaneResize } from "./ui/pane-resize"
@@ -507,7 +507,7 @@ function App(props: { pair?: DialogPairCredentials }) {
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let openingOpen: Promise<SessionInfo[]> | undefined
const [openSessions, setOpenSessions] = createSignal<SessionInfo[]>([])
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@@ -719,14 +719,12 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Open session or project",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
run: async () => {
if (dialog.key === DialogOpenKey || openingOpen) return
const previous = dialog.stack.at(-1)
openingOpen = loadDialogOpen(data, client)
const sessions = await openingOpen
openingOpen = undefined
if (dialog.stack.at(-1) !== previous) return
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
run: () => {
if (dialog.key === DialogOpenKey) return
dialog.replace(() => <DialogOpen sessions={openSessions()} onLoad={setOpenSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
},
},
...Array.from({ length: 9 }, (_, i) => ({
@@ -1213,7 +1211,14 @@ function App(props: { pair?: DialogPairCredentials }) {
})
})
event.on("session.moved", (evt) => {
setOpenSessions((sessions) =>
sessions.map((session) => (session.id !== evt.data.sessionID ? session : moveOpenSession(session, evt))),
)
})
event.on("session.deleted", (evt) => {
setOpenSessions((sessions) => sessions.filter((session) => session.id !== evt.data.sessionID))
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
const title = active?.id === evt.data.sessionID ? active.title : undefined
route.navigate({ type: "home" })
+73 -17
View File
@@ -1,5 +1,5 @@
import { createMemo, createResource, createSignal } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
@@ -25,18 +25,7 @@ export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
data.project.sync().catch(() => {}),
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
])
return sessions
}
export function DialogOpen(props: { sessions: SessionInfo[] }) {
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
const dialog = useDialog()
const route = useRoute()
const data = useData()
@@ -51,6 +40,41 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
let closed = false
onCleanup(() => {
closed = true
})
const [recent] = createResource(() => {
// A late read must not overwrite deletion or placement facts observed in flight.
const changed = new Map<string, Extract<OpenCodeEvent, { type: "session.deleted" | "session.moved" }>>()
const unsubscribe = client.event.listen((message) => {
const event = message.details
if (event.type === "session.deleted" || event.type === "session.moved") changed.set(event.data.sessionID, event)
})
onCleanup(unsubscribe)
return client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => {
if (!closed)
props.onLoad(
response.data.flatMap((session) => {
const event = changed.get(session.id)
if (!event) return [session]
if (event.type === "session.deleted") return []
return [moveOpenSession(props.sessions.find((entry) => entry.id === session.id) ?? session, event)]
}),
)
return true
})
.catch(() => false)
.finally(unsubscribe)
})
const [projects] = createResource(() =>
data.project.sync().then(
() => true,
() => false,
),
)
const [matched] = createResource(
() => {
@@ -154,12 +178,34 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
emptyView={
<Show when={!recent.loading && !projects.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No recent sessions or projects</text>
</box>
</Show>
}
footer={
<box>
<Show when={recent.loading || projects.loading}>
<Spinner color={theme.text.subdued}>Refreshing sessions and projects...</Spinner>
</Show>
<Show when={recent() === false || projects() === false}>
<text fg={theme.text.feedback.error.default}>
Could not refresh{" "}
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
</text>
</Show>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
{recent.loading || projects.loading || matched.loading
? "Searching sessions and projects..."
: shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
@@ -177,6 +223,16 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
)
}
export function moveOpenSession(session: SessionInfo, event: Extract<OpenCodeEvent, { type: "session.moved" }>) {
return {
...session,
location: event.data.location,
projectID: event.data.projectID ?? session.projectID,
subpath: event.data.subpath,
time: { ...session.time, updated: Math.max(session.time.updated, event.created) },
}
}
function timeAgo(timestamp: number) {
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
if (minutes < 1) return "now"
+26 -19
View File
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { createMemo, For, onCleanup, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { stringWidth } from "../util/string-width"
@@ -20,6 +20,10 @@ export function PatchDiff(props: Props) {
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const nodes = new Map<number, DiffRenderable>()
let gutterFrame: number | undefined
onCleanup(() => {
if (gutterFrame !== undefined) cancelAnimationFrame(gutterFrame)
})
local.ref?.({
hunks: () =>
[...nodes.entries()]
@@ -28,28 +32,31 @@ export function PatchDiff(props: Props) {
.filter((node) => !node.isDestroyed),
})
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
// Registrations share one whole-file pass instead of repeating it for every hunk.
if (gutterFrame !== undefined) return
gutterFrame = requestAnimationFrame(() => {
gutterFrame = undefined
const sides = [...nodes.values()]
.filter((item) => !item.isDestroyed)
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
const after = sides.map((side) =>
Math.max(
0,
...[...side.getLineSigns()].filter(([line]) => line >= 0).map(([, sign]) => stringWidth(sign.after ?? "")),
),
const widths = sides.map((side) => {
const width = { line: 0, sign: 0 }
side.getLineNumbers().forEach((number, line) => {
if (line >= 0) width.line = Math.max(width.line, number)
})
side.getLineSigns().forEach((sign, line) => {
if (line >= 0) width.sign = Math.max(width.sign, stringWidth(sign.after ?? ""))
})
return { digits: width.line.toString().length, after: width.sign }
})
const max = widths.reduce(
(max, width) => ({ digits: Math.max(max.digits, width.digits), after: Math.max(max.after, width.after) }),
{ digits: 0, after: 0 },
)
const maxDigits = Math.max(...digits)
const maxAfter = Math.max(...after)
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
if (!maxDigits) return
sides.forEach((side) => {
const index = sides.indexOf(side)
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
side.setLineNumbers(lineNumbers[index])
side.setLineSigns(signs)
if (!max.digits && attempt < 2) return syncGutters(attempt + 1)
if (!max.digits) return
sides.forEach((side, index) => {
side.setLineSign(-1, { after: " ".repeat(max.after + max.digits - widths[index].digits) })
})
})
}
+2 -2
View File
@@ -284,8 +284,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
selection = option
if (!moved) return
if (
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
store.filter.length > 0
!props.preserveSelection &&
(props.current === undefined || props.focusCurrent === false || store.filter.length > 0)
)
return
scrollAfterLayout(false, option.value)
+235
View File
@@ -8,6 +8,241 @@ import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const refresh = Promise.withResolvers<Response>()
const events = createEventStream()
const cachedSession = {
id: "ses_cached",
title: "Cached session",
projectID: "proj_fixture",
location: { directory: "/fixture" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
requests++
requested.resolve()
if (requests === 1) return response.promise
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
if (requests > 4) return new Response("Unavailable", { status: 503 })
return json({ data: [cachedSession], cursor: {} })
}
if (url.pathname === "/api/project") return projects.promise
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await requested.promise
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Search sessions")
expect(setup.captureCharFrame()).toContain("Refreshing")
projects.resolve(
json([
{
id: "proj_fixture",
canonical: "/fixture",
name: "Fixture project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
]),
)
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
expect(requests).toBe(1)
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("Fixture project")
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Disposed")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
events.emit({
id: "evt_deleted",
created: 1,
type: "session.deleted",
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
data: { sessionID: "ses_cached" },
})
await setup.waitForFrame((frame) => !frame.includes("Cached"))
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.renderer.destroy()
await task
} finally {
response.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
refresh.resolve(json({ data: [], cursor: {} }))
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test.each(["dismissed", "refreshing"])(
"Ctrl-O retains committed movement of a cached-only session while %s",
async (phase) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const refresh = Promise.withResolvers<Response>()
const metadata = Promise.withResolvers<Response>()
const destinationRequested = Promise.withResolvers<void>()
const events = createEventStream()
const cached = {
id: "ses_cached_move",
title: "Cached movement",
projectID: "proj_old",
location: { directory: "/fixture/old" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const locations: string[] = []
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
if (url.searchParams.has("parentID")) {
const parent = url.searchParams.get("parentID")
if (parent && parent !== "null") return json({ data: [], cursor: {} })
}
return requests++ === 0
? json({ data: [cached], cursor: {} })
: refresh.promise.then((response) => response.clone())
}
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${cached.id}/inbox` || url.pathname === `/api/session/${cached.id}/permission`)
return json({ data: [] })
if (url.pathname === "/api/project")
return json(
["old", "new"].map((name) => ({
id: `proj_${name}`,
canonical: `/fixture/${name}`,
name: name === "old" ? "Old" : "New",
time: { created: 1, updated: 2 },
sandboxes: [],
})),
)
if (url.pathname === "/api/location") {
const query = url.searchParams.get("location[directory]") ?? ""
locations.push(query)
if (query.includes("/fixture/new")) {
destinationRequested.resolve()
return json({
directory: "/fixture/new",
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
})
}
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
if (phase === "refreshing") {
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
}
events.emit({
id: "evt_cached_moved",
created: 3,
type: "session.moved",
durable: { aggregateID: cached.id, seq: 1, version: 1 },
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
})
if (phase === "dismissed") {
setup.mockInput.pressKey("o", { ctrl: true })
refresh.resolve(new Response("Unavailable", { status: 503 }))
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
}
if (phase === "refreshing") {
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
refresh.resolve(json({ data: [cached], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
}
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
expect(
setup
.captureCharFrame()
.split("\n")
.find((line) => line.includes(cached.title)),
).toContain("New")
locations.length = 0
setup.mockInput.pressEnter()
await destinationRequested.promise
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
} finally {
refresh.resolve(json({ data: [], cursor: {} }))
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
setup.renderer.destroy()
await task
await server.stop()
}
},
)
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
+255 -16
View File
@@ -1,10 +1,13 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { once } from "node:events"
import { CliRenderEvents, TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
import { createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { DialogOpen, DialogOpenKey } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
@@ -85,7 +88,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
expect(fixture.route.data).toEqual({ type: "session", sessionID })
expect(fixture.location.ref).toEqual(remote)
} finally {
fixture.dispose()
await fixture.dispose()
}
})
@@ -131,7 +134,7 @@ test("shows the current project and opens its root", async () => {
}
})
test("waits for sessions before showing the populated picker", async () => {
test("shows projects while sessions refresh and preserves the selected project", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
const fixture = await renderOpen((url) => {
@@ -157,8 +160,9 @@ test("waits for sessions before showing the populated picker", async () => {
})
try {
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
await fixture.app.waitForFrame((frame) => frame.includes("Second project") && frame.includes("Refreshing"))
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
fixture.app.mockInput.pressArrow("down")
resolveSessions(
json({
@@ -177,8 +181,6 @@ test("waits for sessions before showing the populated picker", async () => {
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
@@ -188,6 +190,240 @@ test("waits for sessions before showing the populated picker", async () => {
}
})
test.each([false, true])("keeps a filtered selection visible after refresh with query reset %s", async (reset) => {
const sessions = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project")
return json([
{
id: "proj_first",
canonical: "/tmp/opencode/first",
name: "First shared project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_second",
canonical: "/tmp/opencode/second",
name: "Second shared project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
return undefined
})
const selectedTitle = () =>
fixture.app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) => span.attributes & TextAttributes.BOLD)
.map((span) => span.text)
.join("")
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second shared project") && frame.includes("Refreshing"))
await fixture.app.mockInput.typeText("shared")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
fixture.app.mockInput.pressArrow("down")
await fixture.app.waitForFrame(() => selectedTitle().includes("Second shared project"))
sessions.resolve(
json({
data: Array.from({ length: 12 }, (_, index) => ({
...recentSession,
id: `ses_shared_${index}`,
title: "shared",
time: { created: 1, updated: index + 3 },
})),
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Open") && !frame.includes("Refreshing"))
// Selection reveal runs on FRAME; the following paint must show the selected row.
const frame = once(fixture.app.renderer, CliRenderEvents.FRAME)
fixture.app.renderer.requestRender()
await frame
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).toContain("Second shared project")
if (reset) {
await fixture.app.mockInput.typeText(" project")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).not.toContain("Second shared project")
}
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({
type: "home",
location: { directory: `/tmp/opencode/${reset ? "first" : "second"}` },
})
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
const recentSession = {
id: "ses_recent",
projectID: "proj_recent",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Recent session",
location: { directory: "/fixture" },
}
test("sessions remain selectable while projects are still loading", async () => {
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [recentSession], cursor: {} })
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
projects.resolve(json([]))
await fixture.dispose()
}
})
test("shows hydrated sessions immediately without waiting for either read", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("keeps an uncached moved session in the first successful refresh", async () => {
const response = Promise.withResolvers<Response>()
const destination = { directory: "/fixture/destination" }
const fixture = await renderOpen((url) => (url.pathname === "/api/session" ? response.promise : undefined))
try {
await fixture.app.waitForFrame((frame) => frame.includes("Refreshing"))
fixture.emit({
id: "evt_uncached_move",
created: 3,
type: "session.moved",
durable: { aggregateID: recentSession.id, seq: 1, version: 1 },
data: { sessionID: recentSession.id, location: destination, projectID: "proj_destination" },
})
// The following event supplies an ordered-stream receipt barrier without hydrating metadata.
fixture.emit({
id: "evt_move_received",
created: 4,
type: "session.execution.started",
durable: { aggregateID: recentSession.id, seq: 2, version: 1 },
data: { sessionID: recentSession.id },
})
await fixture.app.waitFor(() => fixture.data.session.status(recentSession.id) === "running")
expect(fixture.data.session.get(recentSession.id)).toBeUndefined()
response.resolve(
json({
data: [
{ ...recentSession, location: destination, projectID: "proj_destination", time: { created: 1, updated: 3 } },
],
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes(recentSession.title) && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
expect(fixture.location.ref).toEqual(destination)
} finally {
response.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
test("keeps the previous recent list usable when reopening fails to refresh", async () => {
let requests = 0
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session")
return requests++ === 0
? json({ data: [recentSession], cursor: {} })
: new Response("Unavailable", { status: 503 })
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEscape()
await fixture.app.waitForFrame((frame) => !frame.includes("Recent session"))
fixture.open()
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
await fixture.dispose()
}
})
test("shows an initial loading shell instead of reporting an empty list", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame(
(frame) => frame.includes("Search sessions and projects") && frame.includes("Refreshing"),
)
expect(fixture.app.captureCharFrame()).not.toContain("No items available")
await fixture.app.mockInput.typeText("missing")
await fixture.app.waitForFrame((frame) => frame.includes("Searching sessions and projects"))
expect(fixture.app.captureCharFrame()).not.toContain("No matches")
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.app.waitForFrame((frame) => frame.includes("No matches") && !frame.includes("Refreshing"))
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("reports both refresh failures while keeping hydrated sessions usable", async () => {
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session" || url.pathname === "/api/project")
return new Response("Unavailable", { status: 503 })
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions and projects"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
} finally {
await fixture.dispose()
}
})
test("option arrows jump between sections", async () => {
const handler: FetchHandler = (url) => {
if (url.pathname === "/api/session")
@@ -291,20 +527,21 @@ async function renderOpen(
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
let open!: () => void
function Probe() {
const dialog = useDialog()
const client = useClient()
const [sessions, setSessions] = createSignal<SessionInfo[]>([])
route = useRoute()
location = useLocation()
data = useData()
storage = useStorage()
onMount(
() =>
void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
),
)
open = () =>
dialog.replace(() => <DialogOpen sessions={sessions()} onLoad={setSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
onMount(() => void Promise.resolve(beforeOpen?.({ data, location })).then(open))
return null
}
@@ -344,6 +581,8 @@ async function renderOpen(
return {
app,
emit: events.emit,
open: () => open(),
get route() {
return route
},
@@ -2,6 +2,7 @@
import { expect, test } from "bun:test"
import {
BoxRenderable,
CodeRenderable,
DiffRenderable,
ImageRenderable,
MouseButton,
@@ -32,6 +33,7 @@ import { createDialogApi } from "../../../src/plugin/api"
import { ToastProvider } from "../../../src/ui/toast"
import { createSignal, Show } from "solid-js"
import { diffImageFixture } from "../../fixture/diff-image"
import { largeDiffFixture } from "../../fixture/large-diff"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@@ -80,6 +82,154 @@ test("shows an error instead of an empty diff when loading fails", async () => {
}
})
test.each([80, 160])(
"large diffs render automatically and expose their complete content at %i columns",
async (width) => {
const file = largeDiffFixture("lines", 20000)
const viewer = await renderDiffViewer([file], { width, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
expect(findDiffs(viewer.app.renderer.root)).toHaveLength(1)
expect(viewer.commands.has("diff.load_patch")).toBe(false)
expect(viewer.app.captureCharFrame()).not.toContain("Large diff hidden")
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
expect(
viewer.app
.captureCharFrame()
.split("\n")
.find((line) => line.includes(file.tail)),
).toMatch(/20000\s+/)
viewer.app.mockInput.pressKey("g")
viewer.app.mockInput.pressKey("g")
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
expect(findScrollBox(viewer.app.renderer.root)!.scrollTop).toBe(0)
viewer.app.mockInput.pressKey("c", { ctrl: true })
await viewer.app.waitFor(() => viewer.current().type !== "plugin")
expect(viewer.current()).toEqual(startRoute)
} finally {
viewer.app.renderer.destroy()
}
},
)
test("all original hunks stay available for forward, reverse, and end navigation", async () => {
const file = largeDiffFixture("hunks", 1000)
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes('"column_1"'))
await viewer.app.flush()
const hunks = findDiffs(viewer.app.renderer.root)
expect(hunks).toHaveLength(1000)
const scroll = findScrollBox(viewer.app.renderer.root)!
viewer.app.mockInput.pressKey("]")
await viewer.app.flush()
expect(hunks[1].y).toBeGreaterThanOrEqual(scroll.viewport.y + 2)
expect(hunks[1].y).toBeLessThanOrEqual(scroll.viewport.y + 3)
viewer.app.mockInput.pressKey("[")
await viewer.app.flush()
expect(scroll.scrollTop).toBe(0)
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
expect(
viewer.app
.captureCharFrame()
.split("\n")
.find((line) => line.includes(file.tail)),
).toMatch(/39985\s+/)
viewer.app.mockInput.pressKey("g")
viewer.app.mockInput.pressKey("g")
await viewer.app.waitForFrame((frame) => frame.includes('"column_1"'))
expect(findDiffs(viewer.app.renderer.root)[0]).toBe(hunks[0])
expect(findDiffs(viewer.app.renderer.root).at(-1)).toBe(hunks.at(-1))
} finally {
viewer.app.renderer.destroy()
}
})
test("long wrapped lines retain their tails through narrow and wide resizing", async () => {
const file = largeDiffFixture("long", 20000)
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes("xxxx"))
await viewer.app.flush()
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
viewer.app.resize(80, 24)
await viewer.app.flush()
expect(findDiffs(viewer.app.renderer.root)[0].view).toBe("unified")
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
viewer.app.resize(160, 30)
await viewer.app.flush()
expect(findDiffs(viewer.app.renderer.root)[0].view).toBe("split")
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
viewer.app.mockInput.pressKey("g")
viewer.app.mockInput.pressKey("g")
await viewer.app.waitForFrame((frame) => frame.includes("xxxx"))
} finally {
viewer.app.renderer.destroy()
}
})
test("large file review and source replacement keep the full patch usable", async () => {
const file = largeDiffFixture("lines", 20000)
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
viewer.app.mockInput.pressKey("m")
await viewer.app.flush()
expect(findDiffs(viewer.app.renderer.root)).toHaveLength(0)
viewer.app.mockInput.pressKey("m")
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
const previous = findDiffs(viewer.app.renderer.root)[0]
viewer.app.mockInput.pressKey("d")
await viewer.app.waitForFrame((frame) => frame.includes("Switch source"))
viewer.app.mockInput.pressArrow("down")
viewer.app.mockInput.pressEnter()
await viewer.app.waitForFrame((frame) => frame.includes("Main branch") && frame.includes('"column_0"'))
expect(previous.isDestroyed).toBe(true)
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
} finally {
viewer.app.renderer.destroy()
}
})
test("large diff selection survives offscreen scrolling and same-view resizing", async () => {
const viewer = await renderDiffViewer([largeDiffFixture("lines", 20000)], { width: 160, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
await viewer.app.flush()
const code = findDiffs(viewer.app.renderer.root)[0]
.getChildren()
.flatMap((side) => side.getChildren())
.find((node) => node instanceof CodeRenderable)
if (!code) throw new Error("Missing diff text owner")
const first = code.content.split("\n")[0]
await viewer.app.mockMouse.drag(code.x + 2, code.y, code.x + first.length - 1, code.y)
const selection = viewer.app.renderer.getSelection()
expect(selection?.getSelectedText()).toBe(first.slice(2))
const scroll = findScrollBox(viewer.app.renderer.root)!
scroll.scrollTo(15000)
await viewer.app.flush()
expect(viewer.app.renderer.getSelection()).toBe(selection)
expect(selection?.getSelectedText()).toBe(first.slice(2))
viewer.app.resize(180, 30)
await viewer.app.flush()
expect(selection?.getSelectedText()).toBe(first.slice(2))
scroll.scrollTo(0)
await viewer.app.flush()
expect(selection?.getSelectedText()).toBe(first.slice(2))
expect(code.isDestroyed).toBe(false)
viewer.app.renderer.clearSelection()
expect(viewer.app.renderer.getSelection()).toBeNull()
} finally {
viewer.app.renderer.destroy()
}
})
test("uses the active location when opened outside a session", async () => {
const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } })
try {
@@ -32,7 +32,6 @@ for (const orientation of ["horizontal", "vertical"] as const) {
await using temporary = await tmpdir()
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
const [active, setActive] = createSignal("second")
const [animations, setAnimations] = createSignal(false)
const [newTab, setNewTab] = createSignal(false)
const [preview, setPreview] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
@@ -91,11 +90,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
<ToastProvider>
<DialogProvider>
<box width="100%" height="100%">
<SessionTabs
controller={controller}
orientation={orientation}
animations={animations()}
/>
<SessionTabs controller={controller} orientation={orientation} animations={false} />
</box>
</DialogProvider>
</ToastProvider>
@@ -141,7 +136,6 @@ for (const orientation of ["horizontal", "vertical"] as const) {
}
for (const attention of ["question", "permission"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
await app.renderOnce()
@@ -171,84 +165,39 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
setActive("second")
await app.renderOnce()
setAnimations(true)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
setActive("first")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
setActive("second")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
setStatus(EMPTY_SESSION_TAB_STATUS)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
}
const glyph = "\u2022"
for (const unread of ["activity", "error"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
setAnimations(true)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
await app.renderOnce()
const color = () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color()?.toInts()).toEqual(
const color = app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color?.toInts()).toEqual(
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
)
const brightness = () => {
const value = color()
return value ? value.r + value.g + value.b : undefined
}
const initial = brightness()!
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
await app.renderOnce()
expect(active()).toBe("first")
expect(status().unread).toBeUndefined()
expect(app.captureCharFrame()).toContain(`${glyph} First`)
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
const peak = brightness()!
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
expect(app.captureCharFrame()).toContain(" First")
expect(app.captureCharFrame()).not.toContain(`${glyph} First`)
}
setAnimations(false)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
await app.renderOnce()
setAnimations(true)
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
await app.waitForFrame((frame) => frame.includes("? First"))
await config.update((draft) => {
draft.tabs.indicators = "numbers"
})
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("1 First")
await config.update((draft) => {
draft.tabs.indicators = "status"
})
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
await app.waitForFrame((frame) => frame.includes(`${SPINNER_FRAMES[0]} First`))
setAnimations(false)
setStatus(EMPTY_SESSION_TAB_STATUS)
setActive("second")
await app.renderOnce()
+45
View File
@@ -0,0 +1,45 @@
export function largeDiffFixture(kind: "hunks" | "lines" | "long", count: number) {
const file = {
file: "snapshot.json",
additions: kind === "long" ? 1 : count,
deletions: kind === "long" ? 1 : count,
status: "modified" as const,
}
const prefix = "--- a/snapshot.json\n+++ b/snapshot.json\n"
if (kind === "long") {
return {
...file,
patch: `${prefix}@@ -1 +1 @@\n-${"x".repeat(count)}_OLD_END\n+${"y".repeat(count)}_NEW_END\n`,
tail: "_NEW_END",
}
}
if (kind === "lines") {
return {
...file,
patch:
prefix +
`@@ -1,${count} +1,${count} @@\n` +
Array.from({ length: count }, (_, index) => `- "column_${index}": ${index},\n`).join("") +
Array.from({ length: count }, (_, index) => `+ "column_${index}": ${index + 1},\n`).join(""),
tail: `"column_${count - 1}"`,
}
}
return {
...file,
patch:
prefix +
Array.from({ length: count }, (_, index) => {
const start = index * 40 + 1
return (
`@@ -${start},25 +${start},25 @@\n` +
Array.from({ length: 12 }, (_, offset) => ` "column_${start + offset}": ${start + offset},\n`).join("") +
`- "column_${start + 12}": ${start + 12},\n+ "column_${start + 12}": ${start + 13},\n` +
Array.from(
{ length: 12 },
(_, offset) => ` "column_${start + 13 + offset}": ${start + 13 + offset},\n`,
).join("")
)
}).join(""),
tail: `"column_${(count - 1) * 40 + 25}"`,
}
}
+1
View File
@@ -193,6 +193,7 @@ const layer = Layer.effect(
}
const tree = yield* reify({ dir, add: [pkg] })
if (isMutable(parsed)) refreshed.add(pkg)
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)
+14 -3
View File
@@ -220,11 +220,22 @@ const layer = Layer.effect(
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const streams =
options?.includeStderr === true
? yield* handle.stderr.pipe(
Stream.broadcastN({ n: 2, capacity: 16 }),
Effect.map((copies) => ({
source: Stream.merge(handle.stdout, copies[0]),
diagnostics: copies[1],
})),
)
: { source: handle.stdout, diagnostics: handle.stderr }
const stderrFiber = yield* Effect.forkScoped(
collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
collectStream(streams.diagnostics, options?.maxErrorBytes).pipe(
Effect.map((x) => x.buffer.toString("utf8")),
),
)
const source = options?.includeStderr === true ? handle.all : handle.stdout
const lines = source.pipe(
const lines = streams.source.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter((line) => line.length > 0),
+9 -7
View File
@@ -40,13 +40,15 @@ Project-specific configuration can use either form:
/home/user/projects/my-app/.opencode/opencode.json(c)
```
When OpenCode starts, it searches for configuration files from the current
directory upward to the project root. It merges direct `opencode.json(c)` files
from the project root toward the current directory, then does the same for
files inside `.opencode` directories. A `.opencode` config therefore overrides
every direct config, even when the direct config is closer to the current
directory. Avoid mixing the two forms across one project hierarchy unless this
precedence is intentional.
During ordinary project discovery, OpenCode searches for configuration files
from the current Location directory through every ancestor to the filesystem
root, including directories above the detected project or repository root. It
merges direct `opencode.json(c)` files from the farthest ancestor toward the
current directory, then does the same for files inside `.opencode` directories.
A discovered `.opencode` config therefore overrides every discovered direct
config, even when the direct config is closer to the current directory. Avoid
mixing the two forms across one directory hierarchy unless this precedence is
intentional.
For example, consider a monorepo with OpenCode started from
`/home/user/projects/acme/packages/web`:
@@ -231,6 +231,18 @@ Use permission actions to hide or deny a server's tools without stopping its con
}
```
## Session context
When OpenCode invokes an MCP tool on behalf of a session, it includes the invoking
session's ID in `CallToolRequest.params._meta.sessionID`. This applies to direct tool
calls and Code Mode over both stdio and Streamable HTTP.
The ID is request metadata, not a tool argument, so it does not appear in the
model-visible tool schema. Treat it as an opaque correlation value: it identifies the
invoking OpenCode session rather than the MCP transport session, can be absent for
calls without session context, and must not be used by itself for authentication or
authorization. Remote MCP servers receive the raw ID and may log or retain it.
## Manage servers
OpenCode interfaces can add servers to project or global configuration, list
+4 -3
View File
@@ -93,9 +93,10 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and
Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay
pinned. Changes to unwatched local dependencies may still require restarting OpenCode.
Changes under watched config directories reload automatically. Server startup loads cached package plugins immediately,
then refreshes unpinned npm and Git plugins in the background. A refreshed package becomes active the next time the server
starts. Exact npm versions and full Git commit hashes stay pinned. Changes to unwatched local dependencies may still require
restarting OpenCode.
```sh
touch .opencode/plugins/concise.ts